/** * The six memory verbs — `remember`/`recall`/`facts`/`why`/`forget`/ * `handoff` — as native `dsh-tools` tools over a per-call `MemoryBackend`. * * Descriptions are reused VERBATIM from `apps/mcp/src/tools.ts`'s MCP * registrations (StateCore's canonical wording for these verbs, * including its 500-character note-cap phrasing) so a model sees the same * guidance whether the backend is reached through MCP or natively. * * `MemoryBackend`'s verb methods (`statecore-mcp/lib`'s `dist/lib.d.ts`) * take no `AbortSignal`: they are synchronous-shaped SQLite/HTTP round trips * with no cancellable long-running phase, so `exec.signal` has nothing to * forward to; every `execute` below reads `exec` only for `exec.agent`. * * `facts()`/`recall()`/`why()` are typed `Promise` on `MemoryBackend` * (the interface both the embedded and HTTP backends satisfy, whose concrete * result shapes can differ) — each `execute` below casts the awaited result to * the shape this module declares in `output.schema`, verified against * `apps/mcp/src/embedded.ts` (`attachFactIds`/`recall`/`buildFactProvenance` * in `packages/core/src/provenance.ts`), the only concrete implementation * available to author these schemas against. * * @module dsh-statecore/tools */ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, GenericResultView, JsonValue, ToolRunContext } from '@deepseek-ai/dsh-tools' import type { MemoryBackend } from 'statecore-mcp/lib' import { createScopeCache, resolveDshScope, type BackendPool } from './backend.js' import type { StatecoreConfig } from './config.js' /** `facts()`'s concrete result shape (`apps/mcp/src/embedded.ts`'s `attachFactIds`). */ type FactsValue = Array<{ group: string items: Array<{ factKey: string; text: string; createdAt: string | null; factId: string | null }> }> /** `why()`'s concrete result shape (`packages/core/src/provenance.ts`'s `buildFactProvenance`); `null` when `factId` is unknown. */ type WhyValue = { fact: JsonValue; chain: JsonValue[] } | null /** * Register the six memory-verb tools into `ctx`'s tool registry. Each call * resolves its own `MemoryBackend` from `pool`, scoped to the calling * agent's session (`exec.agent.session`, via the same `createScopeCache` * `src/ingest.ts`/`src/inject.ts` already use), falling back to the process * default scope (`resolveDshScope`) when a call carries no agent — a direct * `ctx.tools.execute()` call outside an agent loop, e.g. from a test or a * transport that never sets `ToolExecutionInput.agent`. Resolving per call, * not once at registration, is what makes a call from a session whose * `header.cwd` differs from the process default read and write that * session's own project memory instead of silently landing in another * project's scope. * * Each `ctx.tools.register()` call ties its registration's effect ownership * to `ctx`'s own fiber (verified empirically: disposing the fiber that * called `register()` removes the registration without an explicit wrapping * `ctx.effect()` — the same pattern every `dsh-tool-*` package uses), so a * caller mounting this function's owning plugin through `ctx.plugin()` gets * HMR-safe unregistration on dispose for free. * @param ctx - plugin context whose fiber owns these six registrations. * @param config - resolved plugin configuration; accepted for signature * parity with `registerIngest`/`registerInject`, not read directly here. * @param pool - the shared per-scope backend pool, same instance * `registerIngest`/`registerInject` acquire from. */ export function registerTools(ctx: Context, config: StatecoreConfig, pool: BackendPool): void { void config const scopeCache = createScopeCache() /** * Resolve the `MemoryBackend` for one call: the calling agent's session * scope when `exec` carries an agent, else the process default scope. * @param exec - the pending call's runtime context. * @returns the backend this call reads and writes. */ async function backendFor(exec: ToolRunContext): Promise { const scope = exec.agent !== undefined ? scopeCache.scopeFor(exec.agent.session) : resolveDshScope() return pool.acquire(scope) } ctx.tools.register(defineTool({ name: 'remember', description: 'Store a durable fact about this project or user. Use for preferences, decisions, constraints, and anything worth knowing next session. Deterministic and audit-tracked. Notes (the default path) are capped at 500 characters; pass consolidate=true for longer conversational context, accepted up to 2000 characters and distilled in the background.', parameters: { text: { type: 'string', required: true, description: 'The fact text to remember (1-2000 characters).' }, consolidate: { type: 'boolean', description: 'Pass true to accept up to 2000 characters as conversational content distilled into facts in the background, instead of the default 500-character note cap.', }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', const: true, required: true, description: 'Always true on success; failures throw instead.' }, mode: { type: 'string', enum: ['note', 'event'], required: true, description: '`note` upserted immediately; `event` was appended for later digesting.' }, // statecore-mcp ≥ 0.4 (note path) and ≥ 1.0 (event path) add these; // the schema is `additionalProperties: false`, so every field the // engine can return must be declared or the tool call fails validation. superseded: { type: 'string', description: 'Note path: the content of the active note this one replaced, when the engine recognised a revision.' }, distillation: { type: 'string', enum: ['scheduled', 'deferred'], description: 'Event path: whether a background distillation pass was queued (`scheduled`) or must wait for a model / an explicit `digest` (`deferred`).' }, reason: { type: 'string', description: 'Event path, when deferred: why distillation did not run (`no model configured` or `background digest disabled`).' }, }, }, render: (_args, value) => [ { type: 'text', text: value.mode === 'event' && value.distillation === 'deferred' ? `Remembered (event; distillation deferred${value.reason ? `: ${value.reason}` : ''}).` : `Remembered (${value.mode}).`, }, ], }, presentCall: (args): GenericCallView => ({ card: 'generic', title: `Remember: ${args.text.length > 60 ? `${args.text.slice(0, 60)}…` : args.text}`, }), presentResult: (args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Remember failed' : `Remembered: ${args.text.length > 60 ? `${args.text.slice(0, 60)}…` : args.text}`, }), async execute(args, exec) { if (args.text.length < 1 || args.text.length > 2000) { throw new Error(`invalid text: expected 1-2000 characters, got ${args.text.length}`) } if (!args.consolidate && args.text.length > 500) { throw new Error('notes are capped at 500 characters; pass consolidate: true for longer conversational content') } const backend = await backendFor(exec) return backend.remember({ text: args.text, consolidate: args.consolidate }) }, })) ctx.tools.register(defineTool({ name: 'recall', description: 'Retrieve project memory relevant to a query, packed into a character budget. Returns the distilled digest, believed facts, recent events, and a budget report of what was left out. Call at the start of a session or before relying on past context.', parameters: { query: { type: 'string', description: 'Free-text query used to rank relevant facts and events.' }, maxChars: { type: 'integer', description: 'Character budget for the packed result (default 4000, max 32000).' }, }, output: { // The retrieve result's exact fields differ between the embedded and // HTTP backends and across `apps/mcp/src/embedded.ts`'s two branches // (a `budget` field only when `maxChars` was requested, which this // tool always does) — an honestly open object rather than a schema // that promises fields no backend guarantees. schema: { type: 'object', additionalProperties: true }, render: (args, value) => { const maxChars = args.maxChars ?? 4000 const digestPresent = value.digest !== null && value.digest !== undefined return [{ type: 'text', text: `Recalled within a ${maxChars}-character budget; digest ${digestPresent ? 'present' : 'absent'}.` }] }, }, presentCall: (args): GenericCallView => ({ card: 'generic', title: `Recall: ${args.query ?? '(general)'}`, kind: 'search', }), presentResult: (args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Recall failed' : `Recalled: ${args.query ?? '(general)'}`, }), async execute(args, exec) { const maxChars = args.maxChars ?? 4000 if (maxChars <= 0) throw new Error(`invalid maxChars: expected a positive integer, got ${maxChars}`) if (maxChars > 32000) throw new Error(`invalid maxChars: expected at most 32000, got ${maxChars}`) const backend = await backendFor(exec) return (await backend.recall({ query: args.query, maxChars })) as Record }, })) ctx.tools.register(defineTool({ name: 'facts', description: 'List everything currently believed about this project, grouped, with fact ids. Use to review or audit the memory.', parameters: {}, output: { schema: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { group: { type: 'string', required: true }, items: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: { factKey: { type: 'string', required: true }, text: { type: 'string', required: true }, createdAt: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true, description: 'ISO-8601 timestamp, or null when unavailable.' }, factId: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true, description: 'Id to pass to `why`, or null when this item has no registry entry.' }, }, }, }, }, }, }, render: (_args, value) => { const total = value.reduce((sum, group) => sum + group.items.length, 0) const text = value.length === 0 ? 'No facts are currently believed.' : `${total} fact(s) across ${value.length} group(s): ${value.map((group) => group.group).join(', ')}.` return [{ type: 'text', text }] }, }, presentCall: (): GenericCallView => ({ card: 'generic', title: 'List facts', kind: 'search' }), presentResult: (_args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Facts lookup failed' : 'Facts', }), async execute(_args, exec) { const backend = await backendFor(exec) return (await backend.facts()) as FactsValue }, })) ctx.tools.register(defineTool({ name: 'why', description: "Explain why a fact is believed: its source evidence and the full version chain, including superseded and retired versions. Pass a factId from facts or recall.", parameters: { factId: { type: 'string', required: true, description: 'A `factId` from `facts` or `recall`.' }, }, output: { schema: { oneOf: [ { type: 'null', description: 'No fact matches the given factId.' }, { type: 'object', // `FactRegistryEntry`'s exact fields are the digest engine's // internal shape (packages/core/src/digest-control.ts), not part // of this tool's declared contract, so both the entry and this // object stay open. additionalProperties: true, properties: { fact: { type: 'json', required: true, description: 'The evidence-chain registry entry this factId names.' }, chain: { type: 'array', required: true, items: { type: 'json' }, description: 'The full supersession chain, oldest version first.' }, }, }, ], }, render: (args, value) => { if (value === null) return [{ type: 'text', text: `No fact found for id "${args.factId}".` }] return [{ type: 'text', text: `Fact "${args.factId}" is believed; its version chain has ${value.chain.length} entr${value.chain.length === 1 ? 'y' : 'ies'}.` }] }, }, presentCall: (args): GenericCallView => ({ card: 'generic', title: `Why: ${args.factId}`, kind: 'search' }), presentResult: (args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Why lookup failed' : `Why: ${args.factId}`, }), async execute(args, exec) { if (args.factId.length < 1) throw new Error('invalid factId: expected a non-empty string') const backend = await backendFor(exec) return (await backend.why({ factId: args.factId })) as WhyValue }, })) ctx.tools.register(defineTool({ name: 'forget', description: 'Suppress a fact by factKey. The record is retired, not deleted — the audit chain is preserved.', parameters: { factKey: { type: 'string', required: true, description: 'A `factKey` from `facts` or `recall`.' }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', const: true, required: true, description: 'Always true on success; failures throw instead.' }, }, }, render: (args) => [{ type: 'text', text: `Forgot fact "${args.factKey}".` }], }, presentCall: (args): GenericCallView => ({ card: 'generic', title: `Forget: ${args.factKey}` }), presentResult: (args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Forget failed' : `Forgot: ${args.factKey}`, }), async execute(args, exec) { if (args.factKey.length < 1) throw new Error('invalid factKey: expected a non-empty string') const backend = await backendFor(exec) return backend.forget({ factKey: args.factKey }) }, })) ctx.tools.register(defineTool({ name: 'handoff', description: 'Record where this session stopped — a summary, open questions, and next steps — before ending or compacting. The next session (in this client or any other MCP client) receives it at the top of recall; each handoff supersedes the previous one on an auditable chain (why on the returned handoffId walks it). Pass clear=true to retire the active handoff instead.', parameters: { summary: { type: 'string', description: 'Where this session stopped (trimmed, max 2000 characters). Required unless clear is true.' }, openQuestions: { type: 'array', items: { type: 'string' }, description: 'Up to 10 open questions (each trimmed, 1-500 characters).', }, nextSteps: { type: 'array', items: { type: 'string' }, description: 'Up to 10 next steps (each trimmed, 1-500 characters).', }, clear: { type: 'boolean', description: 'Pass true to retire the active handoff instead of recording a new one.' }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', const: true, required: true, description: 'Always true on success; failures throw instead.' }, handoffId: { type: 'string', description: 'Id of the recorded handoff (pass to `why` for the stop-point chain); absent on clear.' }, superseded: { type: 'boolean', required: true, description: 'Whether this handoff replaced a previously active one.' }, cleared: { type: 'boolean', description: 'Present (true) when `clear: true` retired the active handoff.' }, }, }, render: (_args, value) => [{ type: 'text', text: value.cleared === true ? 'Handoff cleared.' : `Handoff recorded${value.superseded ? ' (superseded the previous one)' : ''}.`, }], }, presentCall: (args): GenericCallView => ({ card: 'generic', title: args.clear === true ? 'Clear handoff' : `Handoff: ${(args.summary ?? '').length > 60 ? `${args.summary!.slice(0, 60)}…` : args.summary ?? ''}`, }), presentResult: (args, result): GenericResultView => ({ card: 'generic', title: result.isError ? 'Handoff failed' : args.clear === true ? 'Handoff cleared' : 'Handoff recorded', }), async execute(args, exec) { // Mirrors the MCP registration's zod bounds (`apps/mcp/src/tools.ts`): // summary trimmed/max 2000; each list trimmed, 10 items of 1-500 chars. const summary = args.summary?.trim() if (args.clear !== true && (summary === undefined || summary.length === 0)) { throw new Error('summary is required unless clear is true') } if (summary !== undefined && summary.length > 2000) { throw new Error(`invalid summary: expected at most 2000 characters, got ${summary.length}`) } const trimList = (name: string, list: string[] | undefined): string[] | undefined => { if (list === undefined) return undefined if (list.length > 10) throw new Error(`invalid ${name}: expected at most 10 items, got ${list.length}`) return list.map((item) => { const trimmed = item.trim() if (trimmed.length < 1 || trimmed.length > 500) { throw new Error(`invalid ${name} item: expected 1-500 characters after trimming, got ${trimmed.length}`) } return trimmed }) } const backend = await backendFor(exec) return backend.handoff({ summary, openQuestions: trimList('openQuestions', args.openQuestions), nextSteps: trimList('nextSteps', args.nextSteps), clear: args.clear, }) }, })) }