diff --git a/src/index.ts b/src/index.ts index 2017f29..e18d8b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,18 @@ export { MemoryClient } from "./client.js"; export type { MemoryClientOptions } from "./client.js"; -export { Memories, renderMemoriesPrompt, DEFAULT_PROMPT_TEMPLATE } from "./memories.js"; -export type { IngestOptions, RequestContext } from "./memories.js"; +export { + Memories, + renderMemoriesPrompt, + renderLessonProcedurePrompt, + DEFAULT_PROMPT_TEMPLATE, +} from "./memories.js"; +export type { + IngestOptions, + PreToolHookOptions, + PreToolHookResult, + RequestContext, +} from "./memories.js"; export { Groups } from "./groups.js"; @@ -25,6 +35,7 @@ export { } from "./errors.js"; export type { + ActionContext, ApiErrorBody, ArtifactDetails, ArtifactMemory, @@ -33,6 +44,10 @@ export type { FactDetails, FactMemory, Filter, + LessonMemory, + LessonProcedureDetails, + LessonProcedureType, + ProcedureMemory, Group, GroupCreateRequest, GroupListEnvelope, @@ -58,6 +73,8 @@ export type { SearchListEnvelope, SearchMode, SearchRequest, + TriggerRequest, + TriggerResponse, WebhookConfig, WebhookConfigRequest, WebhookCompletedEvent, diff --git a/src/memories.ts b/src/memories.ts index e6ea2ba..26c673b 100644 --- a/src/memories.ts +++ b/src/memories.ts @@ -1,19 +1,26 @@ import type { HttpClient } from "./http.js"; import { Jobs } from "./jobs.js"; import type { + ActionContext, IngestJob, IngestRequest, GroupListEnvelope, + LessonMemory, + LessonProcedureType, ListEnvelope, ListQuery, Memory, + ProcedureMemory, PromptTemplate, RecallParams, RecallResult, RecallScopeStat, ScopePool, SearchListEnvelope, + SearchMode, SearchRequest, + TriggerRequest, + TriggerResponse, } from "./types.js"; /** @@ -170,6 +177,26 @@ export function renderMemoriesPrompt( return [t.header, ...sections].join("\n\n"); } +/** + * Render recalled `lesson` / `procedure` rows into a single inject-ready block — + * deterministic, no LLM. Used for `mode: "retrieve"` (which returns no + * server-assembled `context`) and as the default for `preToolHook`'s render + * override. Under `compose`, prefer the server's `context` string, which is + * assembled the same way with the gate's `because` rationale attached. + */ +export function renderLessonProcedurePrompt( + rows: Array, +): string { + if (rows.length === 0) return ""; + const lines = ["Relevant lessons & procedures:"]; + for (const r of rows) { + const tag = (r.details.fact_type ?? r.type).toUpperCase(); + const why = r.details.because; + lines.push(`- **[${tag}]** ${r.text}${why ? ` — ${why}` : ""}`); + } + return lines.join("\n"); +} + export interface IngestOptions { /** * If true, hold the connection up to 30s server-side and return the full @@ -186,6 +213,41 @@ export interface RequestContext { requestId?: string; } +export interface PreToolHookOptions extends RequestContext { + /** Scope — at least one axis required (validated client-side). */ + user_id?: string; + group_ids?: string[]; + agent_id?: string; + app_id?: string; + /** Current goal; gates recalled insights under `compose`. */ + task?: string; + /** Pipeline depth. Defaults to `"compose"` (relevance-gated + assembled block). */ + mode?: SearchMode; + /** Restrict the corpora recalled. Defaults to both `lesson` + `procedure`. */ + include?: LessonProcedureType[]; + /** + * Hard client-side timeout in ms. Opt-in: the server already bounds recall to + * ~5s and fails soft, so leave this unset unless you need a tighter cap. A + * value below the server budget can clip a normal `compose` call. + */ + timeoutMs?: number; + /** Override the `retrieve`-mode block renderer (unused under `compose`). */ + render?: (rows: Array) => string; +} + +export interface PreToolHookResult { + /** + * Inject-ready block. The server's assembled `context` under `compose`; the + * client-rendered block under `retrieve`. Empty string when nothing matched + * or the call degraded. + */ + context: string; + /** The raw matched `lesson` / `procedure` rows. */ + memories: Array; + /** True when the call failed soft (network error / timeout) and returned empty. */ + degraded: boolean; +} + export class Memories { readonly jobs: Jobs; @@ -246,6 +308,126 @@ export class Memories { return response; } + /** + * Procedural-memory recall for a pre-tool-call hook (`POST /v1/memories/trigger`). + * + * Fires the symbol tripwire on the in-flight `action` (or explicit `entities`) + * and returns the `lesson` / `procedure` insights past sessions recorded about + * those symbols — advisory, not a mandate. No query, and **not** metered + * against the monthly quota, so it's safe to call before every tool use. + * `mode: "compose"` (default) additionally runs an LLM relevance gate over + * `task` and assembles a markdown block into `context`; `retrieve` returns the + * raw matched rows and leaves `context` null. + * + * Throws on HTTP errors like {@link search}. For a hot-path hook that must + * never block the agent's tool loop, use {@link preToolHook}, which fails soft. + */ + async trigger(body: TriggerRequest, context: RequestContext = {}): Promise { + const { body: response } = await this.http.request("POST", "/v1/memories/trigger", { + body, + signal: context.signal, + requestId: context.requestId, + }); + return response; + } + + /** + * Fail-soft sugar over {@link trigger} built for a pre-tool-call hook. + * + * Pass the tool call the agent is about to make (`{ tool, args, output? }`) or + * pre-extracted `{ entities }`, plus at least one scope axis. Returns the + * inject-ready `context` block (server-assembled under `compose`, + * client-rendered under `retrieve`) alongside the raw rows. + * + * Unlike {@link trigger}, this **never throws on a network error or timeout** — + * it returns `{ context: "", memories: [], degraded: true }` so a recall + * hiccup can't stall the tool loop (the server already fails soft too). It + * *does* throw synchronously on misconfiguration — no scope axis, or no firing + * signal — since those are caller bugs, not transient failures. + * + * @example + * const { context } = await client.memories.preToolHook( + * { tool: "Edit", args: { file_path: "tool_loop.py" } }, + * { user_id: "alice", task: "fix finalize-on-abort" }, + * ); + * if (context) systemPrompt += `\n\n${context}`; + */ + async preToolHook( + signal: ActionContext | { entities: string[] }, + opts: PreToolHookOptions, + ): Promise { + const mode = opts.mode ?? "compose"; + + const isEntities = + "entities" in signal && Array.isArray((signal as { entities: unknown }).entities); + const action = isEntities ? undefined : (signal as ActionContext); + const entities = isEntities ? (signal as { entities: string[] }).entities : undefined; + + // Fail LOUD on caller bugs, before any network work — an empty result here + // would silently mask a missing scope or firing signal. + if (!opts.user_id && !(opts.group_ids && opts.group_ids.length > 0) && !opts.agent_id && !opts.app_id) { + throw new Error( + "preToolHook(): at least one scope axis (user_id, group_ids, agent_id, or app_id) is required", + ); + } + const hasActionSignal = + !!action && (action.tool != null || action.args != null || action.output != null); + if (!hasActionSignal && !(entities && entities.length > 0)) { + throw new Error( + "preToolHook(): pass an `action` (with at least tool/args/output) or non-empty " + + "`entities` — recall fires on the symbols the agent is touching, not a query", + ); + } + + const body: TriggerRequest = { + ...(action ? { action } : {}), + ...(entities ? { entities } : {}), + mode, + include: opts.include, + task: opts.task, + user_id: opts.user_id, + group_ids: opts.group_ids, + agent_id: opts.agent_id, + app_id: opts.app_id, + }; + + // Combine an optional client-side timeout with the caller's own signal so + // either aborts the request; both resolve to a soft, empty result below. + const controller = new AbortController(); + const onAbort = () => controller.abort(); + let timer: ReturnType | undefined; + let listenedSignal: AbortSignal | undefined; + if (opts.timeoutMs != null) timer = setTimeout(onAbort, opts.timeoutMs); + if (opts.signal) { + if (opts.signal.aborted) controller.abort(); + else { + // Track the signal we attach to so the listener is removed in `finally`. + // `{ once: true }` alone leaks here: a normally-completing call never + // fires `abort`, so on a long-lived caller signal reused across a busy + // tool loop each preToolHook would leave a dangling listener. + opts.signal.addEventListener("abort", onAbort, { once: true }); + listenedSignal = opts.signal; + } + } + + try { + const resp = await this.trigger(body, { signal: controller.signal, requestId: opts.requestId }); + const rows = resp.data ?? []; + const context = + mode === "compose" + ? (resp.context ?? "") + : (opts.render ?? renderLessonProcedurePrompt)(rows); + return { context, memories: rows, degraded: false }; + } catch { + // Any network error / timeout / abort degrades to empty — the hook must + // not block the agent's tool call. + return { context: "", memories: [], degraded: true }; + } finally { + if (timer) clearTimeout(timer); + listenedSignal?.removeEventListener("abort", onAbort); + } + } + /** * 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/trigger.test.ts b/src/trigger.test.ts new file mode 100644 index 0000000..f778892 --- /dev/null +++ b/src/trigger.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi } from "vitest"; +import { Memories, renderLessonProcedurePrompt } from "./memories.js"; +import type { HttpClient } from "./http.js"; +import type { LessonMemory, ProcedureMemory, TriggerRequest, TriggerResponse } from "./types.js"; + +/** Minimal lesson/procedure row for fixtures; `over` patches details. */ +function insight( + id: string, + type: "lesson" | "procedure", + text: string, + details: Partial = {}, +): LessonMemory | ProcedureMemory { + 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: [], + matched_on: [], + because: null, + confidence: null, + observation_count: null, + last_confirmed_at: null, + ...details, + }, + } as LessonMemory | ProcedureMemory; +} + +/** + * Fake HttpClient for the trigger endpoint. `onTrigger` builds the response from + * the request body; `fail` (when set) makes every request reject. `calls` + * records the trigger bodies. `signals` records the AbortSignal passed per call. + */ +function fakeHttp(opts: { + onTrigger?: (body: TriggerRequest) => Partial; + fail?: () => never; +}): { http: HttpClient; calls: TriggerRequest[]; signals: (AbortSignal | undefined)[] } { + const calls: TriggerRequest[] = []; + const signals: (AbortSignal | undefined)[] = []; + const http = { + request: async ( + _method: string, + _path: string, + options: { body?: TriggerRequest; signal?: AbortSignal } = {}, + ) => { + const body = options.body as TriggerRequest; + calls.push(body); + signals.push(options.signal); + if (opts.fail) opts.fail(); + const partial = (opts.onTrigger ?? (() => ({})))(body); + const response: TriggerResponse = { + object: "search", + mode: body.mode ?? "compose", + data: [], + context: null, + stage_timings: {}, + context_selection_applied: false, + ...partial, + }; + return { body: response, status: 200, requestId: "req_test" }; + }, + } as unknown as HttpClient; + return { http, calls, signals }; +} + +describe("Memories.trigger", () => { + it("posts action + scope and returns the matched rows", async () => { + const { http, calls } = fakeHttp({ + onTrigger: () => ({ data: [insight("L1", "lesson", "use git -C")] }), + }); + const res = await new Memories(http).trigger({ + action: { tool: "Edit", args: { file_path: "routes.py" } }, + user_id: "alice", + }); + expect(calls[0]!.action?.tool).toBe("Edit"); + expect(calls[0]!.user_id).toBe("alice"); + expect(res.data.map((r) => r.id)).toEqual(["L1"]); + }); +}); + +describe("Memories.preToolHook", () => { + it("defaults to compose and passes the server context through untouched", async () => { + const { http, calls } = fakeHttp({ + onTrigger: () => ({ + data: [insight("L1", "lesson", "use git -C", { because: "shell state resets" })], + context: "## Relevant directives\n- **[LESSON]** use git -C", + }), + }); + const res = await new Memories(http).preToolHook( + { tool: "Bash", args: { command: "cd repo" } }, + { user_id: "alice", task: "run git" }, + ); + expect(calls[0]!.mode).toBe("compose"); + // Server context is forwarded verbatim (its wording is the backend's concern). + expect(res.context).toBe("## Relevant directives\n- **[LESSON]** use git -C"); + expect(res.memories.map((m) => m.id)).toEqual(["L1"]); + expect(res.degraded).toBe(false); + }); + + it("client-renders the block under retrieve (no server context)", async () => { + const { http, calls } = fakeHttp({ + onTrigger: () => ({ + data: [ + insight("L1", "lesson", "use git -C", { because: "shell state resets" }), + insight("P1", "procedure", "fetch then diff"), + ], + }), + }); + const res = await new Memories(http).preToolHook( + { entities: ["git", "VecDB-Backend"] }, + { user_id: "alice", mode: "retrieve" }, + ); + expect(calls[0]!.mode).toBe("retrieve"); + expect(calls[0]!.action).toBeUndefined(); + expect(calls[0]!.entities).toEqual(["git", "VecDB-Backend"]); + expect(res.context).toBe( + "Relevant lessons & procedures:\n" + + "- **[LESSON]** use git -C — shell state resets\n" + + "- **[PROCEDURE]** fetch then diff", + ); + }); + + it("fails soft to an empty, degraded result on a network error", async () => { + const { http } = fakeHttp({ + fail: () => { + throw new Error("ECONNRESET"); + }, + }); + const res = await new Memories(http).preToolHook( + { tool: "Edit", args: {} }, + { user_id: "alice" }, + ); + expect(res).toEqual({ context: "", memories: [], degraded: true }); + }); + + it("throws synchronously when no scope axis is given", async () => { + const { http, calls } = fakeHttp({}); + await expect( + new Memories(http).preToolHook({ tool: "Edit", args: {} }, {}), + ).rejects.toThrow(/scope axis/); + expect(calls).toHaveLength(0); // never hit the network + }); + + it("throws synchronously when there is no firing signal", async () => { + const { http, calls } = fakeHttp({}); + await expect( + new Memories(http).preToolHook({ entities: [] }, { user_id: "alice" }), + ).rejects.toThrow(/action.*or.*entities/s); + expect(calls).toHaveLength(0); + }); + + it("removes the abort listener from a long-lived caller signal after a normal completion", async () => { + const { http } = fakeHttp({ onTrigger: () => ({ data: [] }) }); + const ac = new AbortController(); + const add = vi.spyOn(ac.signal, "addEventListener"); + const remove = vi.spyOn(ac.signal, "removeEventListener"); + await new Memories(http).preToolHook( + { tool: "Edit", args: {} }, + { user_id: "alice", signal: ac.signal, timeoutMs: 5000 }, + ); + // The listener added for the caller's signal must be torn down so a signal + // reused across a busy tool loop doesn't accumulate one dangling listener + // per call. + expect(add).toHaveBeenCalledWith("abort", expect.any(Function), { once: true }); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(ac.signal.aborted).toBe(false); + }); + + it("degrades (does not throw) when the caller's signal is already aborted", async () => { + const { http } = fakeHttp({ + fail: () => { + throw new Error("aborted"); + }, + }); + const res = await new Memories(http).preToolHook( + { tool: "Edit", args: {} }, + { user_id: "alice", signal: AbortSignal.abort() }, + ); + expect(res.degraded).toBe(true); + }); +}); + +describe("renderLessonProcedurePrompt", () => { + it("returns empty string for no rows", () => { + expect(renderLessonProcedurePrompt([])).toBe(""); + }); + + it("tags each row by type and appends the gate rationale when present", () => { + const block = renderLessonProcedurePrompt([ + insight("L1", "lesson", "use git -C", { because: "shell state resets" }), + insight("P1", "procedure", "fetch then diff"), + ]); + expect(block).toBe( + "Relevant lessons & procedures:\n" + + "- **[LESSON]** use git -C — shell state resets\n" + + "- **[PROCEDURE]** fetch then diff", + ); + }); +}); diff --git a/src/types.ts b/src/types.ts index 9127bd8..68d1ae1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,7 +55,10 @@ export interface EpisodeDetails { artifact_ids: string[]; } -interface MemoryBase { +// `T` is widened past `MemoryType` so the lesson/procedure rows returned by the +// trigger endpoint can reuse this shape without joining the `Memory` union (they +// come back only from `POST /v1/memories/trigger`, never from search/list). +interface MemoryBase { id: string; object: "memory"; type: T; @@ -206,6 +209,97 @@ export interface SearchRequest { filters?: Filter; } +// ── Trigger (pre-tool-call recall) ────────────────────────────────────────── +// `POST /v1/memories/trigger` recalls the `lesson` / `procedure` insights past +// sessions recorded about the symbols an in-flight tool call touches. Fired +// before (or just after) a tool runs; advisory, not a mandate. Unlike search it +// takes no query and is not metered against the monthly quota. + +/** The two kinds of insight the trigger endpoint recalls. */ +export type LessonProcedureType = "lesson" | "procedure"; + +/** + * Per-row detail for a `lesson` / `procedure` insight. `because` / `confidence` + * are filled only under `mode: "compose"` (the relevance gate ran); under + * `retrieve` they are null (deterministic symbol-tripwire only). + */ +export interface LessonProcedureDetails { + fact_type: LessonProcedureType | null; + /** The concrete file/symbol anchors this insight fires on. */ + trigger_entities: string[]; + /** The subset of `trigger_entities` the in-flight action actually matched. */ + matched_on: string[]; + /** Why the gate kept this insight for the current task. Null under `retrieve`. */ + because: string | null; + /** Gate confidence (0–1). Null under `retrieve`. */ + confidence: number | null; + /** How many times this insight has been re-confirmed. */ + observation_count: number | null; + last_confirmed_at: string | null; +} + +export type LessonMemory = MemoryBase<"lesson", LessonProcedureDetails>; +export type ProcedureMemory = MemoryBase<"procedure", LessonProcedureDetails>; + +/** + * The in-flight tool call the agent is touching — the firing signal for recall. + * The server extracts greppable identifiers from `tool` / `args` / `output` and + * matches them exactly against insights' trigger anchors. At a pre-tool-call + * hook the high-value signal is `tool` + `args`; `output` is for firing on a + * just-returned 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 post-call. */ + output?: string; +} + +export interface TriggerRequest { + /** + * The in-flight tool call to fire on — the server greps identifiers out of + * `tool` / `args` / `output`. Supply this or `entities`. + */ + action?: ActionContext; + /** + * Escape hatch: pre-extracted identifiers to fire on, bypassing server-side + * extraction from `action`. Use when the client already knows the exact + * symbols the agent is touching. + */ + entities?: string[]; + /** Which corpora to recall. Defaults server-side to both. */ + include?: LessonProcedureType[]; + /** + * The agent's current goal, in one line (≤2000 chars). Fed to the relevance + * gate under `mode: "compose"` so it keeps only insights that help THIS task. + */ + task?: string; + /** Pipeline depth. Defaults server-side to `"compose"`. */ + mode?: SearchMode; + user_id?: string; + group_ids?: string[]; + agent_id?: string; + app_id?: string; +} + +/** + * `POST /v1/memories/trigger` response. `data` carries the matched + * `lesson` / `procedure` rows (`score` is null — precision-gated, not + * vector-scored). Under `mode: "compose"` an assembled markdown block lands in + * `context`; under `retrieve` `context` is null. `stage_timings` is always `{}` + * and `context_selection_applied` always false for this endpoint. + */ +export interface TriggerResponse { + object: "search"; + mode: SearchMode; + data: Array; + context: string | null; + stage_timings: Record; + context_selection_applied: boolean; +} + /** * One scoped search whose results {@link Memories.recall} unions with the others. * Axes within a pool **AND** together (a normal scoped search); recall **unions**