diff --git a/examples/agent_loop_demo.ts b/examples/agent_loop_demo.ts new file mode 100644 index 0000000..a1f4c88 --- /dev/null +++ b/examples/agent_loop_demo.ts @@ -0,0 +1,118 @@ +/** + * Dogfood: a real agent loop with vs. without directive recall. + * + * The agent is asked to make the judge model in `scripts/memory_quality_sweep.py` + * configurable. A captured lesson from a real session says exactly how we like it + * done — "thread `judge_model` through the scoring path instead of hardcoding + * `JUDGE_MODEL`". With `withDirectiveRecall`, that lesson fires the moment the agent + * reads the file and is injected into the read result, so the agent's edit follows + * the team convention. Without it, the agent guesses. + * + * Run: cd memory-sdk-ts && npx tsx examples/agent_loop_demo.ts + * env: OPENAI_API_KEY (gpt-4o-mini via @ai-sdk/openai), XTRACE_API_KEY, XTRACE_ORG_ID, + * optional XTRACE_USER_ID (the user whose directives to recall), XTRACE_BASE_URL. + */ +import { generateText, stepCountIs, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import { MemoryClient } from "../src/index.js"; +import { withDirectiveRecall } from "../src/ai-sdk/index.js"; + +const XTRACE_API_KEY = process.env.XTRACE_API_KEY!; +const XTRACE_ORG_ID = process.env.XTRACE_ORG_ID!; +const USER_ID = process.env.XTRACE_USER_ID ?? "directive-real-0625"; + +// The file the agent will work on (hardcoded judge model — the thing to refactor). +const FILE = "scripts/memory_quality_sweep.py"; +const FILE_CONTENT = `JUDGE_MODEL = "claude-haiku-4-5" # hardcoded + +def _score_one(fact, judge): + resp = judge.classify(fact, model=JUDGE_MODEL) + return resp.label + +def run_sweep(facts): + judge = AnthropicJudge() + return [_score_one(f, judge) for f in facts] +`; + +const client = new MemoryClient({ + apiKey: XTRACE_API_KEY, + orgId: XTRACE_ORG_ID, + baseUrl: process.env.XTRACE_BASE_URL ?? "https://api.staging.xtrace.ai", +}); + +function makeTools(record: { patch?: string }) { + return { + read_file: tool({ + description: "Read a source file. Returns its full text.", + inputSchema: z.object({ file_path: z.string() }), + execute: async () => FILE_CONTENT, + }), + propose_patch: tool({ + description: "Propose the final edited file. Call once you know the change.", + inputSchema: z.object({ file_path: z.string(), new_content: z.string() }), + execute: async ({ new_content }) => { + record.patch = new_content; + return "recorded"; + }, + }), + }; +} + +const TASK = + `Make the judge model in ${FILE} configurable so a caller can choose it per run. ` + + `First read the file, then call propose_patch with the full edited file.`; + +async function runOnce(useDirectives: boolean): Promise { + const record: { patch?: string } = {}; + const fired: string[] = []; + const base = makeTools(record); + const tools = useDirectives + ? withDirectiveRecall(base, { + client, + user_id: USER_ID, + mode: "retrieve", + onDirectives: (ds) => fired.push(...ds.map((d) => `[${d.type}] ${d.text}`)), + }) + : base; + + await generateText({ + model: openai("gpt-4o-mini"), + tools: tools as Parameters[0]["tools"], + prompt: TASK, + stopWhen: stepCountIs(5), + }); + + if (useDirectives) { + console.log(` directives that fired (${fired.length}):`); + fired.forEach((f) => console.log(` ↳ ${f.slice(0, 120)}`)); + } + return record.patch; +} + +(async () => { + if (!process.env.OPENAI_API_KEY) throw new Error("OPENAI_API_KEY not found"); + if (!XTRACE_API_KEY || !XTRACE_ORG_ID) throw new Error("set XTRACE_API_KEY + XTRACE_ORG_ID"); + + console.log("=".repeat(72)); + console.log("RUN A — no directive recall (the agent guesses the convention)"); + console.log("=".repeat(72)); + const a = await runOnce(false); + console.log("\n proposed edit:\n" + indent(a)); + + console.log("\n" + "=".repeat(72)); + console.log("RUN B — withDirectiveRecall (the team's lesson fires on read)"); + console.log("=".repeat(72)); + const b = await runOnce(true); + console.log("\n proposed edit:\n" + indent(b)); + + const threadsB = /def\s+\w+\([^)]*judge_model|model=judge_model|judge_model:/.test(b ?? ""); + const threadsA = /def\s+\w+\([^)]*judge_model|model=judge_model|judge_model:/.test(a ?? ""); + console.log("\n" + "=".repeat(72)); + console.log(`threads judge_model through the call path? A=${threadsA} B=${threadsB}`); + console.log("(the captured lesson: thread judge_model through, don't hardcode JUDGE_MODEL)"); +})(); + +function indent(s?: string): string { + return (s ?? "(no patch proposed)").split("\n").map((l) => " " + l).join("\n"); +} diff --git a/package-lock.json b/package-lock.json index ed1b099..5a5b9de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { + "@ai-sdk/openai": "^2.0.109", "@types/node": "^20.12.0", "ai": "^6.0.190", "openapi-typescript": "^7.4.0", @@ -52,6 +53,54 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/openai": { + "version": "2.0.109", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.109.tgz", + "integrity": "sha512-i2no65RS/08qjB+m3zmAej5AqO4JtTTxdfpWusgA9p73K6TYn7t15h76MZPQVT8tYv5hDR1nHRELuAWaXZyb9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.27.tgz", + "integrity": "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", @@ -1015,6 +1064,7 @@ "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1401,6 +1451,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1789,6 +1840,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1848,6 +1900,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2216,6 +2269,7 @@ "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -2732,6 +2786,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2767,6 +2822,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3394,6 +3450,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f073266..0851dbd 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "prepublishOnly": "npm run typecheck && npm run test && npm run build" }, "devDependencies": { + "@ai-sdk/openai": "^2.0.109", "@types/node": "^20.12.0", "ai": "^6.0.190", "openapi-typescript": "^7.4.0", diff --git a/src/ai-sdk/directives.ts b/src/ai-sdk/directives.ts new file mode 100644 index 0000000..6664bad --- /dev/null +++ b/src/ai-sdk/directives.ts @@ -0,0 +1,121 @@ +/** + * Vercel AI SDK integration: directive recall as a pre-tool-call hook. + * + * Where {@link createXtraceMemory} recalls *semantic* memory once per generation + * on the user message, directives fire per *tool call* on what the agent is about + * to touch. `withDirectiveRecall(tools, opts)` wraps a tools object so that BEFORE + * each tool runs, the directives keyed to that tool's `{ name, args }` are recalled + * (the symbol tripwire) and prepended to the tool's result — so the model sees the + * relevant lesson/procedure alongside what the tool did, before its next move. + * + * Usage: + * + * import { streamText } from 'ai'; + * import { openai } from '@ai-sdk/openai'; + * import { MemoryClient } from '@xtraceai/memory'; + * import { withDirectiveRecall } from '@xtraceai/memory/ai-sdk'; + * + * const client = new MemoryClient({ apiKey, orgId }); + * const result = streamText({ + * model: openai('gpt-4o-mini'), + * tools: withDirectiveRecall(myTools, { client, user_id: 'alice' }), + * messages, + * }); + */ +import type { MemoryClient } from "../client.js"; +import { renderDirectivesPrompt } from "../memories.js"; +import type { DirectiveMemory, SearchMode } from "../types.js"; + +export interface DirectiveRecallScope { + /** Scope directive recall to this user's directives. */ + user_id?: string; + /** Optional agent scope (AND-narrows). */ + agent_id?: string; + /** Optional app scope (AND-narrows). */ + app_id?: string; + /** Optional shared group scope (any-of). */ + group_ids?: string[]; +} + +export interface DirectiveRecallOptions extends DirectiveRecallScope { + /** The memory client used to recall. */ + client: MemoryClient; + /** + * `"retrieve"` (default) — the deterministic stage-1 tripwire, fast (~0.1s), + * right for a per-tool-call hook. `"compose"` adds the stage-2 LLM gate. + */ + mode?: SearchMode; + /** Max directives per tool call. Default 5. */ + limit?: number; + /** + * Prepend the recalled directives to the tool's (string) result so the model + * reads them. Default `true`. Set `false` to leave results untouched and rely + * on {@link DirectiveRecallOptions.onDirectives} for handling. + */ + injectIntoResult?: boolean; + /** Called whenever directives fire for a tool call — for logging / telemetry. */ + onDirectives?: (directives: DirectiveMemory[], toolName: string) => void; +} + +/** + * Recall the directives for one in-flight tool call → rendered context (or `""`). + * Fails soft: a recall error returns no directives rather than throwing. + */ +export async function directiveContextForToolCall( + opts: DirectiveRecallOptions, + toolName: string, + args: Record, +): Promise<{ directives: DirectiveMemory[]; context: string }> { + try { + const { data, context } = await opts.client.memories.recallDirectives({ + action: { tool: toolName, args }, + user_id: opts.user_id, + agent_id: opts.agent_id, + app_id: opts.app_id, + group_ids: opts.group_ids, + mode: opts.mode ?? "retrieve", + limit: opts.limit ?? 5, + }); + return { directives: data, context: context ?? renderDirectivesPrompt(data) }; + } catch { + return { directives: [], context: "" }; + } +} + +/** + * Wrap a Vercel AI SDK tools object with pre-tool-call directive recall. Tools + * with no `execute` are passed through untouched; a recall hiccup never breaks a + * tool (fail-soft). + */ +export function withDirectiveRecall>( + tools: T, + opts: DirectiveRecallOptions, +): T { + const inject = opts.injectIntoResult ?? true; + const out: Record = {}; + for (const [name, t] of Object.entries(tools)) { + const tool = t as { execute?: (...a: unknown[]) => unknown } & Record; + if (typeof tool?.execute !== "function") { + out[name] = t; + continue; + } + const originalExecute = tool.execute.bind(tool); + out[name] = { + ...tool, + execute: async (args: Record, execCtx: unknown) => { + const { directives, context } = await directiveContextForToolCall( + opts, + name, + args ?? {}, + ); + if (directives.length && opts.onDirectives) opts.onDirectives(directives, name); + const result = await originalExecute(args, execCtx); + if (inject && context && typeof result === "string") { + return `${context}\n\n${result}`; + } + return result; + }, + }; + } + return out as T; +} diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index a26a565..5f4c443 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -20,3 +20,6 @@ export type { CreateXtraceMemoryOptions } from "./provider.js"; export { memoryTools } from "./tools.js"; export type { MemoryToolsScope, MemoryToolsOptions } from "./tools.js"; + +export { withDirectiveRecall, directiveContextForToolCall } from "./directives.js"; +export type { DirectiveRecallScope, DirectiveRecallOptions } from "./directives.js"; diff --git a/src/directives.test.ts b/src/directives.test.ts new file mode 100644 index 0000000..0a0d3ce --- /dev/null +++ b/src/directives.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; +import { Memories, renderDirectivesPrompt } from "./memories.js"; +import type { HttpClient } from "./http.js"; +import type { DirectiveMemory } from "./types.js"; +import { withDirectiveRecall } from "./ai-sdk/directives.js"; + +function dir( + id: string, + type: "lesson" | "procedure", + text: string, + entities: string[], + because: string | null = null, +): DirectiveMemory { + return { + id, + object: "memory", + type, + text, + user_id: null, + agent_id: null, + conv_id: null, + app_id: null, + group_ids: [], + categories: [], + score: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + details: { + fact_type: type, + trigger_entities: entities, + matched_on: entities.slice(0, 1), + because, + confidence: because ? 0.8 : null, + observation_count: 1, + last_confirmed_at: null, + }, + }; +} + +function fakeHttp(rows: DirectiveMemory[], context: string | null = null) { + const calls: Array<{ path: string; body: Record }> = []; + const http = { + request: async (_m: string, path: string, opts: { body?: Record } = {}) => { + calls.push({ path, body: opts.body ?? {} }); + return { body: { object: "list", data: rows, context }, status: 200, requestId: "req_test" }; + }, + } as unknown as HttpClient; + return { http, calls }; +} + +describe("recallDirectives", () => { + it("builds the directive-corpus body, retrieve by default", async () => { + const { http, calls } = fakeHttp([dir("l1", "lesson", "finalize on abort", ["tool_loop.py"])]); + const m = new Memories(http); + const res = await m.recallDirectives({ + user_id: "u", + action: { tool: "Edit", args: { file_path: "tool_loop.py" } }, + }); + expect(res.data).toHaveLength(1); + expect(calls[0]!.path).toBe("/v1/memories/search"); + expect(calls[0]!.body.include).toEqual(["lesson", "procedure"]); + expect(calls[0]!.body.mode).toBe("retrieve"); + expect(calls[0]!.body.action).toEqual({ tool: "Edit", args: { file_path: "tool_loop.py" } }); + expect(calls[0]!.body.user_id).toBe("u"); + }); + + it("throws when neither action nor entities is given (firing rule)", async () => { + const { http } = fakeHttp([]); + const m = new Memories(http); + await expect( + m.recallDirectives({ user_id: "u" } as Parameters[0]), + ).rejects.toThrow(/touching|action|entities/i); + }); + + it("throws on a fieldless action", async () => { + const { http } = fakeHttp([]); + const m = new Memories(http); + await expect(m.recallDirectives({ user_id: "u", action: {} })).rejects.toThrow(); + }); + + it("accepts explicit entities + compose mode + task, surfaces context", async () => { + const { http, calls } = fakeHttp([dir("l1", "lesson", "x", ["a.py"], "why")], "## ctx"); + const m = new Memories(http); + const res = await m.recallDirectives({ + user_id: "u", + entities: ["a.py"], + mode: "compose", + task: "fix a.py", + }); + expect(calls[0]!.body.mode).toBe("compose"); + expect(calls[0]!.body.entities).toEqual(["a.py"]); + expect(calls[0]!.body.task).toBe("fix a.py"); + expect(res.context).toBe("## ctx"); + }); +}); + +describe("renderDirectivesPrompt", () => { + it("renders type-tagged bullets with because; empty → ''", () => { + const md = renderDirectivesPrompt([dir("l1", "lesson", "do X", ["a.py"], "it helps")]); + expect(md).toContain("[LESSON]"); + expect(md).toContain("do X"); + expect(md).toContain("it helps"); + expect(renderDirectivesPrompt([])).toBe(""); + }); +}); + +describe("withDirectiveRecall (ai-sdk pre-tool hook)", () => { + it("recalls before exec and prepends directives to a string result", async () => { + const recallDirectives = vi.fn(async (_params: Record) => ({ + data: [dir("l1", "lesson", "finalize on abort", ["tool_loop.py"])], + context: null as string | null, + })); + const client = { memories: { recallDirectives } } as unknown as Parameters< + typeof withDirectiveRecall + >[1]["client"]; + const tools = { Edit: { execute: async () => "edited" } }; + const wrapped = withDirectiveRecall(tools, { client, user_id: "u" }); + const result = await (wrapped.Edit as { execute: (a: unknown, c: unknown) => Promise }) + .execute({ file_path: "tool_loop.py" }, {}); + expect(recallDirectives).toHaveBeenCalledOnce(); + expect(recallDirectives.mock.calls[0]![0]).toMatchObject({ + action: { tool: "Edit", args: { file_path: "tool_loop.py" } }, + }); + expect(result).toContain("finalize on abort"); + expect(result).toContain("edited"); + }); + + it("passes through tools without execute and fails soft on recall error", async () => { + const recallDirectives = vi.fn(async () => { + throw new Error("down"); + }); + const client = { memories: { recallDirectives } } as unknown as Parameters< + typeof withDirectiveRecall + >[1]["client"]; + const tools = { noExec: { description: "x" }, Edit: { execute: async () => "ok" } }; + const wrapped = withDirectiveRecall(tools, { client, user_id: "u" }); + expect(wrapped.noExec).toBe(tools.noExec); + const result = await (wrapped.Edit as { execute: (a: unknown, c: unknown) => Promise }) + .execute({}, {}); + expect(result).toBe("ok"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2017f29..66d6466 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,12 @@ export { MemoryClient } from "./client.js"; export type { MemoryClientOptions } from "./client.js"; -export { Memories, renderMemoriesPrompt, DEFAULT_PROMPT_TEMPLATE } from "./memories.js"; +export { + Memories, + renderMemoriesPrompt, + renderDirectivesPrompt, + DEFAULT_PROMPT_TEMPLATE, +} from "./memories.js"; export type { IngestOptions, RequestContext } from "./memories.js"; export { Groups } from "./groups.js"; @@ -25,9 +30,14 @@ export { } from "./errors.js"; export type { + ActionContext, ApiErrorBody, ArtifactDetails, ArtifactMemory, + DirectiveDetails, + DirectiveListEnvelope, + DirectiveMemory, + DirectiveType, EpisodeDetails, EpisodeMemory, FactDetails, @@ -50,6 +60,7 @@ export type { MemoryType, Message, PromptTemplate, + RecallDirectivesParams, RecallParams, RecallResult, RecallScopeStat, diff --git a/src/memories.ts b/src/memories.ts index e6ea2ba..f339a30 100644 --- a/src/memories.ts +++ b/src/memories.ts @@ -1,6 +1,8 @@ import type { HttpClient } from "./http.js"; import { Jobs } from "./jobs.js"; import type { + DirectiveListEnvelope, + DirectiveMemory, IngestJob, IngestRequest, GroupListEnvelope, @@ -8,6 +10,7 @@ import type { ListQuery, Memory, PromptTemplate, + RecallDirectivesParams, RecallParams, RecallResult, RecallScopeStat, @@ -170,6 +173,24 @@ export function renderMemoriesPrompt( return [t.header, ...sections].join("\n\n"); } +/** + * Render recalled directives into an inject-ready markdown block — deterministic, + * no LLM. One bullet per directive, tagged by type, with the gate's `because` + * appended when present. Returns `""` for an empty list. (Under `mode: "compose"` + * the server already returns this in `DirectiveListEnvelope.context`; use this + * when you recalled with `mode: "retrieve"` and want to render client-side.) + */ +export function renderDirectivesPrompt(directives: DirectiveMemory[]): string { + if (directives.length === 0) return ""; + const lines = ["Relevant directives for what you're about to do:"]; + for (const d of directives) { + const tag = (d.details?.fact_type ?? d.type).toUpperCase(); + const why = d.details?.because ? ` — ${d.details.because}` : ""; + lines.push(`- [${tag}] ${d.text}${why}`); + } + return lines.join("\n"); +} + export interface IngestOptions { /** * If true, hold the connection up to 30s server-side and return the full @@ -246,6 +267,52 @@ export class Memories { return response; } + /** + * Recall situated **directives** (lessons / procedures) for what the agent is + * touching right now — the symbol tripwire. The pre-tool-call read: pass the + * in-flight `action` (the tool + args you're about to run) **or** pre-extracted + * `entities`, and the server fires the directives whose `trigger_entities` + * overlap. Returns 0–N high-precision directives (most-recent first). + * + * `mode` defaults to `"retrieve"` here (the fast, deterministic stage-1 lane — + * right for a per-tool-call hook); pass `"compose"` for the LLM relevance gate + * (precise, ~1s, and fills `because` / `confidence`). + * + * Throws before the request if neither a non-empty `action` nor `entities` is + * given — the server requires a firing signal for the directive corpus (422). + */ + async recallDirectives( + params: RecallDirectivesParams, + context: RequestContext = {}, + ): Promise { + const { action, entities, task, mode, limit, ...scope } = params; + const hasAction = + !!action && [action.tool, action.args, action.output].some((v) => v != null); + const hasEntities = Array.isArray(entities) && entities.length > 0; + if (!hasAction && !hasEntities) { + throw new TypeError( + "recallDirectives requires `action` (the in-flight tool call, with at least " + + "tool/args/output) or `entities` — directives fire on the symbols the agent " + + "is touching, not a query.", + ); + } + const body = { + ...scope, + include: ["lesson", "procedure"], + mode: mode ?? "retrieve", + ...(hasAction ? { action } : {}), + ...(hasEntities ? { entities } : {}), + ...(task ? { task } : {}), + ...(limit !== undefined ? { limit } : {}), + }; + const { body: response } = await this.http.request( + "POST", + "/v1/memories/search", + { body, signal: context.signal, requestId: context.requestId }, + ); + return response; + } + /** * Sugar over {@link search} that forces `mode: "compose"` — the response's * `context` carries xmem's LLM-assembled, ready-to-inject prompt (and `data` diff --git a/src/types.ts b/src/types.ts index 9127bd8..fa42a90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,6 +206,104 @@ export interface SearchRequest { filters?: Filter; } +/** + * The in-flight tool call the agent is about to make — the firing signal for + * directive recall. The server extracts the greppable identifiers from + * `tool` / `args` / `output` and matches them against directives' + * `trigger_entities`. At a pre-tool-call moment the high-value signal is + * `tool` + `args` (the intended call); `output` is for firing off a result. + */ +export interface ActionContext { + /** Tool / MCP name about to run (e.g. `"Edit"`, `"linode_api"`). */ + tool?: string; + /** Intended tool arguments (e.g. `{ file_path: "tool_loop.py" }`). */ + args?: Record; + /** Most-recent tool output, if firing after a call rather than before. */ + output?: string; +} + +/** A situated directive's subtype. */ +export type DirectiveType = "lesson" | "procedure"; + +/** + * Per-row details for a recalled directive. `because` / `confidence` are + * populated only under `mode: "compose"` (the relevance gate ran); they are + * `null` under `mode: "retrieve"`. + */ +export interface DirectiveDetails { + /** `"lesson"` or `"procedure"`. */ + fact_type: string | null; + /** The concrete file/symbol anchors this directive fires on. */ + trigger_entities: string[]; + /** The subset of `trigger_entities` the in-flight action actually matched. */ + matched_on: string[]; + /** Why the gate kept this directive for the task. Null under `retrieve`. */ + because: string | null; + /** Gate confidence (0–1). Null under `retrieve`. */ + confidence: number | null; + /** How many times this directive has been re-confirmed. */ + observation_count: number | null; + last_confirmed_at: string | null; +} + +/** A situated directive (lesson/procedure) recalled by the symbol tripwire. */ +export interface DirectiveMemory { + id: string; + object: "memory"; + type: DirectiveType; + /** The directive statement, ready to inject. */ + text: string; + user_id?: string | null; + agent_id?: string | null; + conv_id?: string | null; + app_id?: string | null; + group_ids: string[]; + categories: string[]; + /** Null — directives are precision-gated, not vector-scored. */ + score: number | null; + created_at: string | null; + updated_at: string | null; + details: DirectiveDetails; +} + +/** Response of {@link Memories.recallDirectives}. */ +export interface DirectiveListEnvelope { + object?: "list"; + /** 0–N high-precision directives, most-recent first. */ + data: DirectiveMemory[]; + /** + * Inject-ready markdown of the kept directives — populated only under + * `mode: "compose"`; `null` under `mode: "retrieve"`. + */ + context?: string | null; +} + +/** + * Parameters for {@link Memories.recallDirectives}. Directives fire on the + * symbols the agent is touching, so **`action` (with at least one of + * `tool`/`args`/`output`) OR `entities` is required** — the server returns + * `422` otherwise. + */ +export interface RecallDirectivesParams { + /** The in-flight tool call; identifiers extracted server-side. */ + action?: ActionContext; + /** Pre-extracted identifiers to fire on (skips server-side extraction). */ + entities?: string[]; + /** The agent's current goal — fed to the relevance gate under `compose`. */ + task?: string; + user_id?: string; + agent_id?: string; + app_id?: string; + group_ids?: string[]; + /** + * `"retrieve"` (the SDK default here) is the deterministic stage-1 tripwire — + * fast (~0.1s), may over-fire. `"compose"` adds the stage-2 LLM relevance + * gate — precise, slower (~1s), and populates `because` / `confidence`. + */ + mode?: SearchMode; + limit?: number; +} + /** * One scoped search whose results {@link Memories.recall} unions with the others. * Axes within a pool **AND** together (a normal scoped search); recall **unions**