diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4513f70 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish to npm + +# Publishes @memmesh/sdk to the npm registry when a version tag is pushed. +# Tag the release commit with `v` (e.g. `v0.9.0`) and push the tag: +# git tag v0.9.0 && git push origin v0.9.0 +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: build + npm publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run typecheck + - run: npm run build + - name: Publish + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index d140509..1eb8b28 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @thinkfleet/memory-sdk +# @memmesh/sdk TypeScript SDK for [app.memmesh.ai](https://app.memmesh.ai) — a managed memory + behavioral-pattern engine for AI agents. @@ -13,17 +13,18 @@ Runs anywhere with a modern `fetch`: Node 18+, Bun, Deno, browsers, Cloudflare W ## Install ```bash -npm install @thinkfleet/memory-sdk -# or: pnpm add @thinkfleet/memory-sdk -# or: bun add @thinkfleet/memory-sdk +npm install @memmesh/sdk +# or: pnpm add @memmesh/sdk +# or: bun add @memmesh/sdk ``` ## Quick start ```ts -import { ThinkFleetMemory } from '@thinkfleet/memory-sdk' +import { MemMesh } from '@memmesh/sdk' +// `ThinkFleetMemory` is the legacy alias — still exported for back-compat. -const tf = new ThinkFleetMemory({ +const tf = new MemMesh({ apiKey: 'sk-...', // Platform Admin → API Keys projectId: 'proj_...', // Default project for all calls }) @@ -51,7 +52,7 @@ const hits = await tf.memory.admin.search({ ## Configuration ```ts -const tf = new ThinkFleetMemory({ +const tf = new MemMesh({ apiKey: 'sk-...', // Required projectId: 'proj_...', // Required default baseUrl: 'https://app.memmesh.ai', // Default @@ -74,7 +75,7 @@ await tf.memory.admin.list({ scope: 'project' }, { projectId: 'proj_other' }) Pass a request interceptor that swaps the `Authorization` header on each call: ```ts -const tf = new ThinkFleetMemory({ +const tf = new MemMesh({ apiKey: 'unused', // still required to be non-empty, but interceptor wins projectId: 'proj_...', requestInterceptors: [ @@ -100,10 +101,33 @@ const tf = new ThinkFleetMemory({ | Method | Endpoint | | ------------------------- | ------------------------------------- | +| `observe(body)` | `POST /projects/:id/memory/observe` | | `mine(params?)` | `GET /projects/:id/memory/mine` | | `delete(memoryId)` | `DELETE /projects/:id/memory/:memId` | | `submitFeedback(body)` | `POST /projects/:id/memory/feedback`| +`observe()` is the primary write path. Hand it the raw turn, **verbatim** — the +engine runs extraction, dedupe, graph wiring, and embedding, and keeps only what +is worth remembering. Do not summarize or pre-filter first: extraction is the +thing you are paying for, and a pre-digested input makes it worse, not cheaper. + +```ts +const { saved, candidateCount } = await tf.memory.observe({ + text: "I just moved to Denver and I'm still vegetarian.", + role: 'user', + userId: 'user-123', // your identifier, recorded as provenance + sessionId: 'thread-456', // keeps a conversation's turns linkable +}) +``` + +`candidateCount` is what extraction proposed; `saved` is what survived dedupe and +the token budget. Filler comes back as `saved: []` — that is the system working. + +`userId` is provenance, **not** a tenancy boundary: `admin.search({ chatIdentityId })` +filters permissively (`IS NULL OR = $1`) so project-wide memories stay visible to +every caller. Isolating one end user's memories from another's needs a project +per tenant. + ### `tf.memory.admin` — admin / project-wide memory | Method | Endpoint | @@ -123,6 +147,38 @@ const tf = new ThinkFleetMemory({ | `delete(memId)` | `DELETE /projects/:id/admin/memory/:memId` | | `listFeedback(memId)` | `GET /projects/:id/admin/memory/:memId/feedback`| +### `tf.memory.admin.graph` — the knowledge graph + +Observing text doesn't only produce embeddable rows; extraction also resolves +entities and writes typed edges between them. That graph is what answers a +question no single memory states outright. + +| Method | Endpoint | +| ------------------------------- | --------------------------------------------------- | +| `stats()` | `GET /projects/:id/admin/memory/graph/stats` | +| `listEntities(params?)` | `GET /projects/:id/admin/memory/entities` | +| `getEntity(entityId, params?)` | `GET /projects/:id/admin/memory/entities/:entityId` | +| `listEdges(params?)` | `GET /projects/:id/admin/memory/graph/edges` | +| `traverse(entityId, params?)` | `POST /projects/:id/admin/memory/graph/traverse` | + +```ts +// How much of what you remember made it into the graph? +const { entityCount, edgeCount, memoriesWithEdges } = await tf.memory.admin.graph.stats() + +// Multi-hop: who does Sarah ultimately report to? +const [sarah] = await tf.memory.admin.graph.listEntities({ search: 'Sarah', limit: 1 }) +const chain = await tf.memory.admin.graph.traverse(sarah.id, { + hops: 2, + predicates: ['member_of', 'led_by'], +}) +``` + +Use `stats()` — not `listEntities().length` — for any "how big is it" question: +the list routes page, so their length is the page size, not the total. + +Read-only by design. Entities and edges are written by extraction when you +`observe()`; a hand-maintained graph is the work the engine exists to do for you. + ### `tf.lattice` — behavioral patterns | Method | Endpoint | @@ -206,7 +262,7 @@ returns the full `PredictResult` (`targetPrediction` + top-level `abstained`). ## Memory scopes ```ts -import { MemoryScope } from '@thinkfleet/memory-sdk' +import { MemoryScope } from '@memmesh/sdk' MemoryScope.PLATFORM // visible to every project on the platform MemoryScope.PROJECT // visible to every user in this project @@ -229,7 +285,7 @@ import { RateLimitError, ServerError, TimeoutError, -} from '@thinkfleet/memory-sdk' +} from '@memmesh/sdk' try { await tf.memory.admin.create({ content: '' }) diff --git a/examples/financial-demo.ts b/examples/financial-demo.ts index 24e15f7..3012122 100644 --- a/examples/financial-demo.ts +++ b/examples/financial-demo.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * @thinkfleet/memory-sdk — financial vertical end-to-end demo + * @memmesh/sdk — financial vertical end-to-end demo * * A working sample app that pulls REAL data from public, no-API-key sources, * loads it into ThinkFleet memory, and reads the financial vertical back out: diff --git a/examples/next-best-offer.ts b/examples/next-best-offer.ts index bfbba28..5cf5a12 100644 --- a/examples/next-best-offer.ts +++ b/examples/next-best-offer.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * @thinkfleet/memory-sdk — Next Best Offer, end to end + * @memmesh/sdk — Next Best Offer, end to end * * A working sample app for the question: *"which offer is right for this * contact, and when is the right time to send it?"* — and, crucially, *how diff --git a/examples/predict-anything.ts b/examples/predict-anything.ts index 774f6f9..15e2952 100644 --- a/examples/predict-anything.ts +++ b/examples/predict-anything.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * @thinkfleet/memory-sdk — v2 "predict anything" + abstention demo + * @memmesh/sdk — v2 "predict anything" + abstention demo * * The whole moat in one file: declare ANY target and the engine predicts it * from a subject's observation history — calibrated, with provenance, and diff --git a/package-lock.json b/package-lock.json index fde0fcd..8fffde3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@thinkfleet/memory-sdk", + "name": "@memmesh/sdk", "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@thinkfleet/memory-sdk", + "name": "@memmesh/sdk", "version": "0.7.1", "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index 412fb8d..7f64695 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@thinkfleet/memory-sdk", + "name": "@memmesh/sdk", "version": "0.9.0", "description": "TypeScript SDK for app.memmesh.ai — admin + project memory CRUD, semantic search, feedback, and Lattice behavioral patterns", "type": "module", @@ -51,8 +51,15 @@ "lattice" ], "license": "MIT", + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "https://github.com/ThinkfleetAI/thinkfleet-memory-sdk.git" + }, + "homepage": "https://github.com/ThinkfleetAI/thinkfleet-memory-sdk#readme", + "bugs": { + "url": "https://github.com/ThinkfleetAI/thinkfleet-memory-sdk/issues" } } diff --git a/src/client.ts b/src/client.ts index 8a48af5..054c72b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,9 +42,9 @@ export interface ThinkFleetMemoryOptions { * * @example * ```ts - * import { ThinkFleetMemory } from '@thinkfleet/memory-sdk' + * import { MemMesh } from '@memmesh/sdk' * - * const tf = new ThinkFleetMemory({ + * const tf = new MemMesh({ * apiKey: 'sk-...', * projectId: 'proj_...', * }) diff --git a/src/index.ts b/src/index.ts index de261aa..5cdca7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ // Client export { ThinkFleetMemory, type ThinkFleetMemoryOptions } from './client.js' +// Brand-consistent alias (matches the Python SDK's `MemMesh` class). +// `ThinkFleetMemory` is retained as a back-compat legacy alias. +export { ThinkFleetMemory as MemMesh } from './client.js' // Core export { @@ -23,6 +26,7 @@ export type { // Resources export { MemoryResource, AdminMemoryResource } from './resources/memory.js' export { ConsentResource } from './resources/consent.js' +export { GraphResource } from './resources/graph.js' export { ContextResource } from './resources/context.js' export type { ContextSection, @@ -248,3 +252,15 @@ export type { SubjectProfile, RiskIndicator, } from './types/lattice.js' + +// Knowledge graph +export type { + MemoryEntity, + MemoryEntityType, + MemoryEdge, + GraphStats, + ListEntitiesParams, + ListEdgesParams, + TraverseParams, + EntityWithEdges, +} from './types/graph.js' diff --git a/src/resources/graph.ts b/src/resources/graph.ts new file mode 100644 index 0000000..7aeb44b --- /dev/null +++ b/src/resources/graph.ts @@ -0,0 +1,94 @@ +import type { HttpClient } from '../core/http-client.js' +import type { RequestOptions } from '../core/types.js' +import type { + EntityWithEdges, + GraphStats, + ListEdgesParams, + ListEntitiesParams, + MemoryEdge, + MemoryEntity, + TraverseParams, +} from '../types/graph.js' + +/** + * The knowledge graph built from observed memory. + * + * Reached as `tf.memory.admin.graph` — it lives under the admin surface because + * every route here is admin-tier (`/admin/memory/...`); a project-scoped key + * gets a 403. + * + * Read-only by design. Entity and edge *creation* happens through extraction + * when you `observe()`; the manual create/retire routes exist on the server for + * annotation tooling, and exposing them here would invite hand-maintained + * graphs, which is exactly the work the engine is supposed to do for you. + */ +export class GraphResource { + constructor(private readonly http: HttpClient) {} + + /** + * Aggregate counts for the whole graph. + * + * Prefer this over `listEntities().length` for any "how big is it" question: + * these are SQL `COUNT(*)`s over the full table, where the list routes page + * and would silently report the page size as the total. + */ + async stats(options?: RequestOptions): Promise { + return this.http.get('/admin/memory/graph/stats', undefined, options) + } + + /** Entities, filtered by type/scope or by a substring of name or alias. */ + async listEntities( + params?: ListEntitiesParams, + options?: RequestOptions, + ): Promise { + return this.http.get( + '/admin/memory/entities', + params as Record, + options, + ) + } + + /** One entity plus its 1-hop neighbourhood. */ + async getEntity( + entityId: string, + params?: { asOf?: string }, + options?: RequestOptions, + ): Promise { + return this.http.get( + `/admin/memory/entities/${entityId}`, + params as Record, + options, + ) + } + + /** + * Every currently-valid edge. Use for rendering a whole small graph; for a + * large one, seed from an entity and `traverse` instead. + */ + async listEdges(params?: ListEdgesParams, options?: RequestOptions): Promise { + return this.http.get( + '/admin/memory/graph/edges', + params as Record, + options, + ) + } + + /** + * Walk out from a seed entity. + * + * This is the multi-hop path: the edges returned here connect facts that no + * single memory states together, which is how a query gets answered from a + * chain rather than from one lucky vector hit. + */ + async traverse( + entityId: string, + params?: TraverseParams, + options?: RequestOptions, + ): Promise { + return this.http.post( + '/admin/memory/graph/traverse', + { entityId, ...params }, + options, + ) + } +} diff --git a/src/resources/memory.ts b/src/resources/memory.ts index 7ff8f6b..876bbf9 100644 --- a/src/resources/memory.ts +++ b/src/resources/memory.ts @@ -1,4 +1,5 @@ import { ConsentResource } from './consent.js' +import { GraphResource } from './graph.js' import type { HttpClient } from '../core/http-client.js' import { renderProcedureContent } from '../core/procedural.js' import type { RequestOptions } from '../core/types.js' @@ -30,6 +31,7 @@ import { type ObserveAttachmentRequest, type ObserveDocumentRequest, type ObserveRequest, + type ObserveResponse, type ObserveVoiceRequest, type PromoteMemoryRequest, type SubmitFeedbackRequest, @@ -141,31 +143,57 @@ export class MemoryResource { async observe( body: ObserveRequest, options?: RequestOptions, - ): Promise { - return this.admin.create( - { - content: body.content, - type: body.type ?? MemoryItemType.EVENT, - scope: body.scope ?? MemoryScope.PROJECT, - importance: body.importance ?? 5, - category: body.category, - source: 'admin_created', - // Event time, not ingest time. `validFrom` is the column the mining - // engine reads to bucket day-of-week / time-of-day, so this mapping is - // what makes backfills work: without it every historical order lands at - // the wall-clock instant of the import and the patterns describe the - // import job. The metadata copy is kept for backwards compatibility - // with readers that already look for `metadata.occurredAt`. - validFrom: body.occurredAt, - metadata: { - subject: body.subject, - ...(body.activityType ? { eventType: body.activityType } : {}), + ): Promise { + // PRIMARY path: hand the engine the raw turn and let it decide what to keep. + // POST /memory/observe runs the Observe pipeline (extract → dedupe → budget) + // and returns { saved, candidateCount }; filler comes back with saved: []. + if (body.text != null && body.text.trim().length > 0) { + return this.http.post( + '/memory/observe', + { + text: body.text, + role: body.role ?? 'user', + // Provenance, all optional server-side. Omitted rather than sent as + // null so a turn without them is indistinguishable from one made by + // an older client. + ...(body.userId ? { userId: body.userId } : {}), + ...(body.agentId ? { agentId: body.agentId } : {}), + ...(body.sessionId ? { sessionId: body.sessionId } : {}), ...(body.occurredAt ? { occurredAt: body.occurredAt } : {}), - ...(body.metadata ?? {}), }, - }, - options, - ) + options, + ) + } + // LEGACY path: caller handed a pre-decided fact. Store it verbatim (no + // extraction) and wrap the single item so the return shape stays consistent. + if (body.content != null && body.content.trim().length > 0) { + const item = await this.admin.create( + { + content: body.content, + type: body.type ?? MemoryItemType.EVENT, + scope: body.scope ?? MemoryScope.PROJECT, + importance: body.importance ?? 5, + category: body.category, + source: 'admin_created', + // Event time, not ingest time. `validFrom` is the column the mining + // engine reads to bucket day-of-week / time-of-day, so this mapping is + // what makes backfills work: without it every historical order lands at + // the wall-clock instant of the import and the patterns describe the + // import job. The metadata copy is kept for backwards compatibility + // with readers that already look for `metadata.occurredAt`. + validFrom: body.occurredAt, + metadata: { + subject: body.subject, + ...(body.activityType ? { eventType: body.activityType } : {}), + ...(body.occurredAt ? { occurredAt: body.occurredAt } : {}), + ...(body.metadata ?? {}), + }, + }, + options, + ) + return { saved: [item], candidateCount: 1 } + } + throw new Error('observe requires `text` (preferred) or `content`') } /** @@ -392,7 +420,12 @@ export class MemoryResource { * non-admin keys get a 403. */ export class AdminMemoryResource { - constructor(private readonly http: HttpClient) {} + /** The knowledge graph extraction builds from observed memory. */ + readonly graph: GraphResource + + constructor(private readonly http: HttpClient) { + this.graph = new GraphResource(http) + } /** * List memories in the project, filtered by scope/status/etc. diff --git a/src/types/graph.ts b/src/types/graph.ts new file mode 100644 index 0000000..4673387 --- /dev/null +++ b/src/types/graph.ts @@ -0,0 +1,130 @@ +/** + * Knowledge-graph types — the structural half of memory. + * + * Observing text doesn't only produce embeddable rows; it also resolves + * entities and writes typed edges between them. That graph is what lets a query + * reach a fact no single memory states outright ("who does Sarah report to?" + * answered from `sarah -[member_of]-> team` + `team -[led_by]-> priya`). + * + * Both records are bi-temporal, and the two time axes mean different things: + * - `validFrom` / `validTo` — when the fact was TRUE in the world. + * - `expiredAt` (edges) — when the graph stopped BELIEVING it, because a + * contradicting edge superseded it. + * A fact that was true last year and a fact we were wrong about are not the + * same thing, and collapsing them loses the audit trail. + */ +import type { MemoryScope } from './memory.js' + +/** What kind of thing an entity is. Open-ended — the engine adds types over time. */ +export type MemoryEntityType = string + +export interface MemoryEntity { + id: string + created: string + updated: string + platformId: string + projectId: string | null + /** + * The brain that first created this entity. Entities dedupe per project, so + * this is provenance, NOT an isolation key — use `MemoryEdge.brainId` for + * brain-scoped graph work. + */ + brainId: string | null + locationId: string | null + chatbotId: string | null + chatIdentityId: string | null + scope: MemoryScope + type: MemoryEntityType + /** The name this entity is filed under; aliases resolve to it. */ + canonicalName: string + aliases: string[] + description: string | null + metadata: Record | null + validFrom: string + /** Null while the entity is still current. */ + validTo: string | null + supersededById: string | null +} + +export interface MemoryEdge { + id: string + created: string + updated: string + platformId: string + projectId: string | null + /** Per-brain KG isolation — edges are written fresh per brain, so this one + * IS the enforceable key for brain-scoped walks. Null on legacy edges. */ + brainId: string | null + locationId: string | null + chatbotId: string | null + chatIdentityId: string | null + scope: MemoryScope + /** Entity id this edge starts from. */ + subjectId: string + /** The relationship — `works_at`, `owns`, `located_in`, ... */ + predicate: string + /** Entity id, when the object is itself an entity. */ + objectId: string | null + /** Literal value, when the object is not an entity (a date, a price, "v1.2.3"). */ + objectLiteral: string | null + /** Confidence, 0..1. */ + weight: number + /** The memory this edge was extracted from. */ + sourceMemoryId: string | null + metadata: Record | null + validFrom: string + validTo: string | null + /** Set when a contradicting edge superseded this one. Null = still believed. */ + expiredAt?: string | null +} + +export interface ListEntitiesParams { + type?: MemoryEntityType + scope?: MemoryScope + /** Substring match against `canonicalName` and every alias. */ + search?: string + limit?: number + offset?: number +} + +export interface ListEdgesParams { + /** Read the graph as it stood at this ISO-8601 instant. */ + asOf?: string + /** Default 1000, max 10000. */ + limit?: number +} + +export interface TraverseParams { + /** How many hops out from the seed entity. 1-3. */ + hops?: number + /** Restrict the walk to these predicates, e.g. `['member_of', 'led_by']`. */ + predicates?: string[] + asOf?: string +} + +/** + * Aggregate graph counts. + * + * `memoriesWithEdges` against your total memory count is the useful ratio: it + * says how much of what you remember made it into the graph rather than + * remaining an isolated embedding. A low ratio usually means extraction is off + * or the corpus is prose the extractor found no relations in. + */ +export interface GraphStats { + entityCount: number + edgeCount: number + /** Distinct memories that produced at least one edge. */ + memoriesWithEdges: number + retiredEntities: number + retiredEdges: number + /** Live entity counts keyed by entity type. */ + entitiesByType: Record + /** Whether KG extraction is on, platform-wide and for this project. */ + extraction?: { platformEnabled: boolean; projectEnabled: boolean } +} + +/** An entity plus its 1-hop neighbourhood. */ +export interface EntityWithEdges { + entity: MemoryEntity | null + edges: MemoryEdge[] +} diff --git a/src/types/memory.ts b/src/types/memory.ts index 5ea6e17..637fac1 100644 --- a/src/types/memory.ts +++ b/src/types/memory.ts @@ -77,10 +77,39 @@ export interface MemoryItem extends BaseModel { * scope, importance, etc. */ export interface ObserveRequest { - /** Subject this observation applies to. Required so mining works. */ - subject: { kind: string; externalId: string } - /** Free-text content of the observation. */ - content: string + /** + * Raw message text — the PRIMARY field. Send the whole turn, verbatim; the + * engine runs extraction (heuristic + optional LLM) and keeps only what's + * worth remembering, dropping filler. Prefer this over `content`. + */ + text?: string + /** Who said `text` — defaults to "user". */ + role?: 'user' | 'assistant' | 'system' + /** + * The end user this turn belongs to — your own identifier, not a MemMesh one. + * Recorded as provenance on whatever the engine keeps. + * + * NOT a tenancy boundary. `admin.search({ chatIdentityId })` filters + * permissively (`IS NULL OR = $1`), so project-wide memories stay visible to + * every caller. Isolating one end user's memories from another's needs a + * project per tenant. + */ + userId?: string + /** The agent or assistant that produced this turn. Provenance only. */ + agentId?: string + /** Conversation/thread id, so turns from one session stay linkable. */ + sessionId?: string + /** + * Subject this observation applies to. Used with the legacy `content` path so + * mining can attribute the observation; not needed with `text` (the engine + * resolves subjects during extraction). + */ + subject?: { kind: string; externalId: string } + /** + * DEPRECATED — a pre-decided fact stored verbatim, bypassing extraction. + * Prefer `text` and let the engine decide what to keep. + */ + content?: string /** Event type identifier (`pizza_order`, `code_commit`, ...). Free-form. */ activityType?: string /** ISO-8601 timestamp the activity occurred at. Defaults to now server-side. */ @@ -97,6 +126,16 @@ export interface ObserveRequest { metadata?: Record } +/** + * What `observe` returns: the memories the engine chose to keep (empty when the + * turn was filler) plus how many candidates it found before the dedupe/budget + * pass. `saved.length <= candidateCount`. + */ +export interface ObserveResponse { + saved: MemoryItem[] + candidateCount: number +} + /** * Image / audio attachment request. The `image` field accepts a * Uint8Array (cross-platform), Node Buffer (which extends Uint8Array), diff --git a/test-app.ts b/test-app.ts index 59b15d1..c7379b1 100644 --- a/test-app.ts +++ b/test-app.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * @thinkfleet/memory-sdk — integration smoke test + * @memmesh/sdk — integration smoke test * * Exercises every public SDK method against a live app.memmesh.ai * instance. Read-mostly, with one create+update+delete cycle so the @@ -180,18 +180,29 @@ async function run(): Promise { section('memory.observe() — ergonomic ingest') let observedId: string | null = null - await test('observe() creates a memory with subject metadata', async () => { - const m = await tf.memory.observe({ + await test('observe() (legacy content) creates a memory with subject metadata', async () => { + const res = await tf.memory.observe({ subject: { kind: 'workspace', externalId: `smoke-test-${stamp}` }, content: `SDK_SMOKE_OBSERVE_${stamp} — observed activity from the smoke test`, activityType: 'smoke_test_event', }) - if (!m.id) throw new Error(`observe returned no id: ${JSON.stringify(m)}`) + const m = res.saved[0] + if (!m?.id) throw new Error(`observe returned no saved item: ${JSON.stringify(res)}`) if (m.type !== 'event') throw new Error(`expected type=event, got ${m.type}`) observedId = m.id console.log(` observed id=${m.id} type=${m.type}`) }) + await test('observe() (raw text) routes through the engine noise filter', async () => { + const res = await tf.memory.observe({ + text: `SDK_SMOKE_TEXT_${stamp} — we standardized on pnpm across all repos.`, + role: 'user', + }) + if (!Array.isArray(res.saved)) throw new Error(`expected { saved: [] }, got ${JSON.stringify(res)}`) + if (typeof res.candidateCount !== 'number') throw new Error('expected numeric candidateCount') + console.log(` raw-text observe: saved=${res.saved.length} candidates=${res.candidateCount}`) + }) + await test('observe() carries subject + activityType in metadata', async () => { if (!observedId) throw new Error('skipped — no observedId') const items = await tf.memory.admin.list({ limit: 100 })