dsh-context-memory 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.js CHANGED
@@ -13,6 +13,7 @@ export const MEMORY_RECALL_PATH = '/api/context-memory.recall';
13
13
  export const MEMORY_REMEMBER_PATH = '/api/context-memory.remember';
14
14
  export const MEMORY_FORGET_PATH = '/api/context-memory.forget';
15
15
  export const MEMORY_PREFS_PATH = '/api/context-memory.prefs';
16
+ export const MEMORY_CANDIDATES_PATH = '/api/context-memory.candidates';
16
17
  export function registerMemoryRoutes(ctx, memory) {
17
18
  // Injecting rather than reading `ctx.connection` directly: a plain property access on
18
19
  // a service that was not declared throws, and the web connection is absent in headless
@@ -71,6 +72,8 @@ export function registerMemoryRoutes(ctx, memory) {
71
72
  input.subject = body.subject;
72
73
  if (body.kind)
73
74
  input.kind = body.kind;
75
+ if (body.scope === 'user' || body.scope === 'project')
76
+ input.scope = body.scope;
74
77
  if (body.source)
75
78
  input.source = body.source;
76
79
  const mem = memory.remember(input);
@@ -92,12 +95,58 @@ export function registerMemoryRoutes(ctx, memory) {
92
95
  }
93
96
  if (!body.id)
94
97
  return Response.json({ error: 'missing id' }, { status: 400 });
95
- const ok = memory.forget(body.id, body.reason ?? 'superseded from panel', body.replacedBy);
98
+ const scope = body.scope === 'user' || body.scope === 'project' ? body.scope : undefined;
99
+ const ok = memory.forget(body.id, body.reason ?? 'superseded from panel', {
100
+ ...(body.replacedBy !== undefined ? { replacedBy: body.replacedBy } : {}),
101
+ ...(scope !== undefined ? { scope } : {}),
102
+ });
96
103
  if (!ok)
97
104
  return Response.json({ error: 'not found' }, { status: 404 });
98
105
  return Response.json({ ok: true });
99
106
  },
100
107
  });
108
+ // Candidate decisions — accept writes a memory, dismiss records the answer.
109
+ //
110
+ // Accepting is the only path from a heuristic to the log, which is why it takes an
111
+ // explicit id: the caller names the proposal it is confirming, and there is no route
112
+ // that turns "a sentence that looked like a fact" into a memory on its own.
113
+ connection.fetch.register({
114
+ path: MEMORY_CANDIDATES_PATH,
115
+ methods: ['GET', 'POST'],
116
+ requestBody: 'buffered',
117
+ fetch: async (request) => {
118
+ if (request.method === 'GET') {
119
+ return Response.json({ candidates: memory.candidates() }, { headers: { 'cache-control': 'no-store' } });
120
+ }
121
+ let body;
122
+ try {
123
+ body = await request.json();
124
+ }
125
+ catch {
126
+ return Response.json({ error: 'invalid JSON' }, { status: 400 });
127
+ }
128
+ if (body.id === undefined || body.id.length === 0)
129
+ return Response.json({ error: 'missing id' }, { status: 400 });
130
+ if (body.action === 'dismiss') {
131
+ const ok = memory.dismiss(body.id);
132
+ return ok ? Response.json({ ok: true }) : Response.json({ error: 'not found' }, { status: 404 });
133
+ }
134
+ if (body.action === 'accept') {
135
+ const overrides = {};
136
+ if (body.text !== undefined && body.text.trim().length > 0)
137
+ overrides.text = body.text;
138
+ if (body.kind !== undefined)
139
+ overrides.kind = body.kind;
140
+ if (body.scope === 'user' || body.scope === 'project')
141
+ overrides.scope = body.scope;
142
+ const stored = memory.accept(body.id, overrides);
143
+ return stored === undefined
144
+ ? Response.json({ error: 'not found' }, { status: 404 })
145
+ : Response.json(stored);
146
+ }
147
+ return Response.json({ error: 'action must be accept or dismiss' }, { status: 400 });
148
+ },
149
+ });
101
150
  // Feature switches — GET to read, POST a partial patch to set
102
151
  connection.fetch.register({
103
152
  path: MEMORY_PREFS_PATH,
package/lib/scope.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where a memory lives.
3
+ *
4
+ * Two scopes, because "the build uses pnpm" and "answer me in Chinese" are stored in
5
+ * different places for different reasons. A project convention is only true inside that
6
+ * checkout and should travel with it; a working preference is true everywhere the user
7
+ * works and should not be re-stated in every repository.
8
+ *
9
+ * The scope is a property of the memory, not of the installation: one log holds both,
10
+ * and each entry is routed to the store its scope names. That keeps one fold and one
11
+ * panel while still letting the two populations move independently.
12
+ *
13
+ * @module context-memory/scope
14
+ */
15
+ /**
16
+ * Directory names that mark the top of a project, in probe order.
17
+ *
18
+ * `package.json` is deliberately absent. It is the marker everyone reaches for and it is
19
+ * the wrong one: a stray `package.json` in a home directory makes every unrelated folder
20
+ * beneath it one project, so a scratch directory on the Desktop would share a memory log
21
+ * with everything else in the profile. `.git` marks a checkout, and a workspace file only
22
+ * appears at the top of a workspace, which is what a project root means here.
23
+ *
24
+ * This matches `dsh-agent-instructions`, whose default `projectRootMarkers` is `['.git']`.
25
+ */
26
+ export declare const PROJECT_ROOT_MARKERS: readonly [".git", "pnpm-workspace.yaml"];
27
+ /** Where the user-global log lives, under the harness home. */
28
+ export declare function userStoreDir(): string;
29
+ /**
30
+ * Find the top of the project containing `start`.
31
+ *
32
+ * Mirrors `dsh-agent-instructions`'s discovery: climb while the marker is *absent*, and
33
+ * stop at the first directory that carries one. The difference from `process.cwd()` is
34
+ * the whole point — a session started three levels down inside a monorepo is still the
35
+ * same project, and two directories that are not the same project must not share a log.
36
+ *
37
+ * When no marker is found anywhere on the way up, `start` is its own project — which is
38
+ * honest: there is no evidence that anything above it belongs to the same work.
39
+ *
40
+ * @param start - absolute directory to begin from.
41
+ * @returns the discovered project root, or `start` when nothing marks one.
42
+ */
43
+ export declare function findProjectRoot(start: string): string;
44
+ /** Resolve the directory a project memory is stored under. */
45
+ export declare function resolveProjectStore(cwd?: string): string;
package/lib/scope.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Where a memory lives.
3
+ *
4
+ * Two scopes, because "the build uses pnpm" and "answer me in Chinese" are stored in
5
+ * different places for different reasons. A project convention is only true inside that
6
+ * checkout and should travel with it; a working preference is true everywhere the user
7
+ * works and should not be re-stated in every repository.
8
+ *
9
+ * The scope is a property of the memory, not of the installation: one log holds both,
10
+ * and each entry is routed to the store its scope names. That keeps one fold and one
11
+ * panel while still letting the two populations move independently.
12
+ *
13
+ * @module context-memory/scope
14
+ */
15
+ import { statSync } from 'node:fs';
16
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
17
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
18
+ /**
19
+ * Directory names that mark the top of a project, in probe order.
20
+ *
21
+ * `package.json` is deliberately absent. It is the marker everyone reaches for and it is
22
+ * the wrong one: a stray `package.json` in a home directory makes every unrelated folder
23
+ * beneath it one project, so a scratch directory on the Desktop would share a memory log
24
+ * with everything else in the profile. `.git` marks a checkout, and a workspace file only
25
+ * appears at the top of a workspace, which is what a project root means here.
26
+ *
27
+ * This matches `dsh-agent-instructions`, whose default `projectRootMarkers` is `['.git']`.
28
+ */
29
+ export const PROJECT_ROOT_MARKERS = ['.git', 'pnpm-workspace.yaml'];
30
+ /** Where the user-global log lives, under the harness home. */
31
+ export function userStoreDir() {
32
+ return dshHomePath('context-memory');
33
+ }
34
+ /**
35
+ * Find the top of the project containing `start`.
36
+ *
37
+ * Mirrors `dsh-agent-instructions`'s discovery: climb while the marker is *absent*, and
38
+ * stop at the first directory that carries one. The difference from `process.cwd()` is
39
+ * the whole point — a session started three levels down inside a monorepo is still the
40
+ * same project, and two directories that are not the same project must not share a log.
41
+ *
42
+ * When no marker is found anywhere on the way up, `start` is its own project — which is
43
+ * honest: there is no evidence that anything above it belongs to the same work.
44
+ *
45
+ * @param start - absolute directory to begin from.
46
+ * @returns the discovered project root, or `start` when nothing marks one.
47
+ */
48
+ export function findProjectRoot(start) {
49
+ const origin = resolve(start);
50
+ let current = origin;
51
+ for (;;) {
52
+ for (const marker of PROJECT_ROOT_MARKERS) {
53
+ if (exists(join(current, marker)))
54
+ return current;
55
+ }
56
+ const parent = dirname(current);
57
+ if (parent === current)
58
+ return origin;
59
+ current = parent;
60
+ }
61
+ }
62
+ /** `statSync` that answers a yes/no question without throwing on absence. */
63
+ function exists(path) {
64
+ try {
65
+ statSync(path);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Resolve the directory a project memory is stored under. */
73
+ export function resolveProjectStore(cwd) {
74
+ return findProjectRoot(cwd !== undefined && isAbsolute(cwd) ? cwd : resolve('.'));
75
+ }
package/lib/store.d.ts CHANGED
@@ -11,11 +11,31 @@
11
11
  */
12
12
  import type { MemoryEvent } from './types.js';
13
13
  export declare class MemoryStore {
14
- private readonly projectDir;
15
14
  private readonly dir;
16
15
  private readonly logPath;
17
- constructor(projectDir: string);
16
+ /**
17
+ * The parsed log, together with the file identity it was parsed from.
18
+ *
19
+ * This is not the cache the module comment warns about. That one was rejected because
20
+ * it would let one process's view of history outlive the events that contradict it;
21
+ * this one is revalidated against the file's own size and mtime on every read, so an
22
+ * append by another process is seen on the next call exactly as before. What it avoids
23
+ * is re-parsing an unchanged file — the same log is folded several times inside one
24
+ * pre-step, and parsing it once per fold is the only part of that which is avoidable.
25
+ */
26
+ private cached;
27
+ /** @param dir - the `.memory` directory itself, already resolved for its scope. */
28
+ constructor(dir: string);
18
29
  get path(): string;
30
+ /**
31
+ * An identity for the file as it is right now, or null when there is no file.
32
+ *
33
+ * Size alone would miss a rewrite; mtime alone is too coarse on some Windows
34
+ * filesystems. Together they change on every append this process or another makes.
35
+ * `stat` costs a syscall; reading and parsing the file costs a syscall plus an
36
+ * allocation plus a JSON.parse per line, which is why this is worth doing.
37
+ */
38
+ signature(): string | null;
19
39
  append(event: MemoryEvent): void;
20
40
  /**
21
41
  * Read the whole log.
@@ -25,6 +45,9 @@ export declare class MemoryStore {
25
45
  * recoverable loss for a total one — so a torn *final* line is dropped. Corruption
26
46
  * anywhere else is a real problem and is reported rather than skipped, because
27
47
  * silently ignoring a damaged record is how a log stops being evidence.
48
+ *
49
+ * The returned array is shared with the cache: treat it as read-only.
28
50
  */
29
51
  readAll(): MemoryEvent[];
52
+ private parse;
30
53
  }
package/lib/store.js CHANGED
@@ -9,25 +9,55 @@
9
9
  *
10
10
  * @module context-memory/store
11
11
  */
12
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  export class MemoryStore {
15
- projectDir;
16
15
  dir;
17
16
  logPath;
18
- constructor(projectDir) {
19
- this.projectDir = projectDir;
20
- this.dir = join(projectDir, '.memory');
17
+ /**
18
+ * The parsed log, together with the file identity it was parsed from.
19
+ *
20
+ * This is not the cache the module comment warns about. That one was rejected because
21
+ * it would let one process's view of history outlive the events that contradict it;
22
+ * this one is revalidated against the file's own size and mtime on every read, so an
23
+ * append by another process is seen on the next call exactly as before. What it avoids
24
+ * is re-parsing an unchanged file — the same log is folded several times inside one
25
+ * pre-step, and parsing it once per fold is the only part of that which is avoidable.
26
+ */
27
+ cached;
28
+ /** @param dir - the `.memory` directory itself, already resolved for its scope. */
29
+ constructor(dir) {
30
+ this.dir = dir;
21
31
  this.logPath = join(this.dir, 'events.jsonl');
22
32
  }
23
33
  get path() {
24
34
  return this.logPath;
25
35
  }
36
+ /**
37
+ * An identity for the file as it is right now, or null when there is no file.
38
+ *
39
+ * Size alone would miss a rewrite; mtime alone is too coarse on some Windows
40
+ * filesystems. Together they change on every append this process or another makes.
41
+ * `stat` costs a syscall; reading and parsing the file costs a syscall plus an
42
+ * allocation plus a JSON.parse per line, which is why this is worth doing.
43
+ */
44
+ signature() {
45
+ try {
46
+ const stats = statSync(this.logPath);
47
+ return `${stats.size}:${stats.mtimeMs}`;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
26
53
  append(event) {
27
54
  if (!existsSync(this.dir))
28
55
  mkdirSync(this.dir, { recursive: true });
29
56
  // One `write` of one line: a crash can truncate the final line, but cannot interleave.
30
57
  appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf8');
58
+ // Not strictly needed — the next `signature()` no longer matches — but it keeps the
59
+ // cached array from being handed out after this process has moved the log on.
60
+ this.cached = undefined;
31
61
  }
32
62
  /**
33
63
  * Read the whole log.
@@ -37,10 +67,18 @@ export class MemoryStore {
37
67
  * recoverable loss for a total one — so a torn *final* line is dropped. Corruption
38
68
  * anywhere else is a real problem and is reported rather than skipped, because
39
69
  * silently ignoring a damaged record is how a log stops being evidence.
70
+ *
71
+ * The returned array is shared with the cache: treat it as read-only.
40
72
  */
41
73
  readAll() {
42
- if (!existsSync(this.logPath))
43
- return [];
74
+ const key = this.signature();
75
+ if (this.cached !== undefined && this.cached.key === key)
76
+ return this.cached.events;
77
+ const events = key === null ? [] : this.parse();
78
+ this.cached = { key, events };
79
+ return events;
80
+ }
81
+ parse() {
44
82
  const lines = readFileSync(this.logPath, 'utf8').split('\n');
45
83
  const events = [];
46
84
  for (let index = 0; index < lines.length; index += 1) {
package/lib/tools.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Model-facing tools: the pull half of memory.
3
+ *
4
+ * Recall injection pushes what the plugin thinks is relevant. That is one direction only,
5
+ * and it leaves the model unable to ask the question it most often needs answered:
6
+ * "what did we already decide about this?" This module registers that question as two
7
+ * tools, so memory is something the model can consult rather than only something that
8
+ * arrives unasked.
9
+ *
10
+ * The two directions deliberately answer differently. The push path may only offer
11
+ * guidance — active and not contradicted — because unsolicited text influences the turn.
12
+ * The pull path is answering a question someone actually asked, so it can widen to
13
+ * contradicted or superseded records when asked to by name. The difference is carried by
14
+ * the `include` argument rather than by a second implementation, and `guidance` is still
15
+ * the default: a model that does not think about populations gets the same conservatism
16
+ * the push path applies.
17
+ *
18
+ * @module context-memory/tools
19
+ */
20
+ import type { Context } from '@deepseek-ai/cordis';
21
+ import type { Agent } from '@deepseek-ai/dsh-agent';
22
+ import type { ContextMemory } from './memory.js';
23
+ /** How this plugin finds the log belonging to one call's recording context. */
24
+ export interface MemoryToolOptions {
25
+ /**
26
+ * The instance for the session that issued the call.
27
+ *
28
+ * A tool call belongs to an agent, and an agent belongs to a session with its own
29
+ * working directory — which may not be the project the last pre-step resolved. Reading
30
+ * the shared "current" instance instead would answer a question about one project with
31
+ * another project's log.
32
+ */
33
+ resolve(agent: Agent | undefined, signal: AbortSignal): Promise<ContextMemory>;
34
+ }
35
+ /**
36
+ * Register both tools, once the host's tool registry exists.
37
+ *
38
+ * Registered through `ctx.inject` rather than a static `inject` entry for the same reason
39
+ * the working directory is read through `ctx.get`: this plugin must keep working on a host
40
+ * that mounts no tool registry (the CLI, a headless profile without tools), and a declared
41
+ * dependency on `tools` would leave the whole plugin pending — including the pre-step
42
+ * injection and the panel — on such a host. The tools are an addition, not a precondition.
43
+ */
44
+ export declare function registerMemoryTools(ctx: Context, options: MemoryToolOptions): void;
package/lib/tools.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Model-facing tools: the pull half of memory.
3
+ *
4
+ * Recall injection pushes what the plugin thinks is relevant. That is one direction only,
5
+ * and it leaves the model unable to ask the question it most often needs answered:
6
+ * "what did we already decide about this?" This module registers that question as two
7
+ * tools, so memory is something the model can consult rather than only something that
8
+ * arrives unasked.
9
+ *
10
+ * The two directions deliberately answer differently. The push path may only offer
11
+ * guidance — active and not contradicted — because unsolicited text influences the turn.
12
+ * The pull path is answering a question someone actually asked, so it can widen to
13
+ * contradicted or superseded records when asked to by name. The difference is carried by
14
+ * the `include` argument rather than by a second implementation, and `guidance` is still
15
+ * the default: a model that does not think about populations gets the same conservatism
16
+ * the push path applies.
17
+ *
18
+ * @module context-memory/tools
19
+ */
20
+ import { defineTool } from '@deepseek-ai/dsh-tools';
21
+ import { describeContradiction, describeGuard } from './guard.js';
22
+ /** Populations a caller may search, in the order the schema lists them. */
23
+ const TARGETS = ['guidance', 'contradicted', 'superseded', 'all'];
24
+ /** Kinds a memory may be recorded as. Mirrors `MemoryKind`. */
25
+ const KINDS = ['fact', 'decision', 'preference', 'pitfall'];
26
+ /** Scopes a memory may be written to. Mirrors `MemoryScope`. */
27
+ const SCOPES = ['project', 'user'];
28
+ /** Bounds on one search, so a model cannot ask for the whole log by accident. */
29
+ const MIN_LIMIT = 1;
30
+ const MAX_LIMIT = 20;
31
+ const DEFAULT_LIMIT = 5;
32
+ /** Clamp a requested result count into the range one call may return. */
33
+ function clampLimit(requested) {
34
+ if (requested === undefined || !Number.isFinite(requested))
35
+ return DEFAULT_LIMIT;
36
+ const whole = Math.trunc(requested);
37
+ return Math.min(Math.max(whole, MIN_LIMIT), MAX_LIMIT);
38
+ }
39
+ /**
40
+ * The verification word for one memory, as the model sees it.
41
+ *
42
+ * `unverified` and `broken` are kept apart from `violated` for the same reason the guard
43
+ * module keeps them apart: a check that could not run is not evidence about the memory.
44
+ * Collapsing them into "failed" would teach the model to distrust true statements whose
45
+ * guard happens to be broken.
46
+ */
47
+ function verificationOf(memory) {
48
+ const outcome = memory.verification?.outcome;
49
+ if (outcome === undefined)
50
+ return 'unverified';
51
+ return outcome;
52
+ }
53
+ /** The first line of a failed check, for a caller that wants to know why. */
54
+ function contradictionOf(memory) {
55
+ return describeContradiction(memory.guard, memory.verification?.output);
56
+ }
57
+ /** One result row in the tool's output shape. */
58
+ function hitOf(memory) {
59
+ return {
60
+ id: memory.id,
61
+ text: memory.text,
62
+ kind: memory.kind,
63
+ scope: memory.scope,
64
+ verification: verificationOf(memory),
65
+ // Only an actual contradiction gets a reason. `broken` says the check is unusable,
66
+ // and attaching its message here would read as evidence against the memory.
67
+ ...(memory.verification?.outcome === 'violated' ? { detail: contradictionOf(memory) } : {}),
68
+ };
69
+ }
70
+ /**
71
+ * Register both tools, once the host's tool registry exists.
72
+ *
73
+ * Registered through `ctx.inject` rather than a static `inject` entry for the same reason
74
+ * the working directory is read through `ctx.get`: this plugin must keep working on a host
75
+ * that mounts no tool registry (the CLI, a headless profile without tools), and a declared
76
+ * dependency on `tools` would leave the whole plugin pending — including the pre-step
77
+ * injection and the panel — on such a host. The tools are an addition, not a precondition.
78
+ */
79
+ export function registerMemoryTools(ctx, options) {
80
+ ctx.inject(['tools'], (toolCtx) => {
81
+ toolCtx.tools.register(defineTool({
82
+ name: 'memory_recall',
83
+ description: 'Search this project\'s stored memories (conventions, decisions, preferences, pitfalls recorded in earlier sessions) and return the strongest matches with their verification state. By default only memories that are active and not contradicted are returned; pass include to widen. Omit query to list the newest memories instead.',
84
+ parameters: {
85
+ query: {
86
+ type: 'string',
87
+ description: 'What to look for. Latin words match as words, Chinese text matches on two-character runs. Omit to list the newest memories.',
88
+ },
89
+ limit: {
90
+ type: 'integer',
91
+ description: `How many memories to return. ${MIN_LIMIT}-${MAX_LIMIT}, default ${DEFAULT_LIMIT}.`,
92
+ },
93
+ include: {
94
+ type: 'string',
95
+ enum: [...TARGETS],
96
+ description: 'Which memories to search: guidance (default) is active and not contradicted; contradicted is active but disproved by a check; superseded was retired; all is everything recorded.',
97
+ },
98
+ },
99
+ output: {
100
+ schema: {
101
+ type: 'object',
102
+ additionalProperties: false,
103
+ properties: {
104
+ hits: {
105
+ type: 'array',
106
+ required: true,
107
+ description: 'Matches, strongest first.',
108
+ items: {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ properties: {
112
+ id: { type: 'string', required: true, description: 'Reference for this memory.' },
113
+ text: { type: 'string', required: true, description: 'The remembered statement.' },
114
+ kind: { type: 'string', required: true, description: 'fact, decision, preference, or pitfall.' },
115
+ scope: { type: 'string', required: true, description: 'project or user.' },
116
+ verification: { type: 'string', required: true, description: 'passed, violated, broken, or unverified.' },
117
+ detail: { type: 'string', description: 'Why a check contradicted it, when one did.' },
118
+ },
119
+ },
120
+ },
121
+ include: { type: 'string', required: true, description: 'The population actually searched.' },
122
+ note: { type: 'string', description: 'Present when the search returned nothing, or when it fell back to newest-first.' },
123
+ },
124
+ },
125
+ render: (_args, value) => {
126
+ const lines = [];
127
+ if (value.hits.length === 0) {
128
+ lines.push(`No stored memory matched (searched: ${value.include}).`);
129
+ }
130
+ else {
131
+ lines.push(`${value.hits.length} memory${value.hits.length === 1 ? '' : 'ies'} (searched: ${value.include}):`);
132
+ for (const hit of value.hits) {
133
+ const detail = hit.detail === undefined ? '' : ` — contradicted by ${hit.detail}`;
134
+ lines.push(`- [${hit.kind}] ${hit.text} (id ${hit.id}, ${hit.scope}, ${hit.verification})${detail}`);
135
+ }
136
+ }
137
+ if (value.note !== undefined)
138
+ lines.push('', value.note);
139
+ return [{ type: 'text', text: lines.join('\n') }];
140
+ },
141
+ },
142
+ isConcurrencySafe: () => true,
143
+ async execute(args, exec) {
144
+ const memory = await options.resolve(exec.agent, exec.signal);
145
+ const target = (args.include ?? 'guidance');
146
+ const limit = clampLimit(args.limit);
147
+ const query = args.query?.trim() ?? '';
148
+ // An empty query is a question about nothing, and a lexical search would answer it
149
+ // with nothing. "What do you remember" is a real question, so it gets the newest
150
+ // entries of the requested population instead.
151
+ if (query.length === 0) {
152
+ const hits = memory.recent(limit, target);
153
+ return {
154
+ hits: hits.map(hitOf),
155
+ include: target,
156
+ ...(hits.length > 0 ? { note: 'No query was given, so these are the newest memories rather than the most relevant.' } : {}),
157
+ };
158
+ }
159
+ return {
160
+ hits: memory.recall(query, limit, Date.now(), target).map((hit) => hitOf(hit.memory)),
161
+ include: target,
162
+ };
163
+ },
164
+ }));
165
+ toolCtx.tools.register(defineTool({
166
+ name: 'memory_remember',
167
+ description: 'Record a durable memory for this project or for the user, so later sessions see it. Use it for conventions, decisions, preferences, and pitfalls the user states or that the work establishes — not for transient task state. The statement is stored as text; only a few narrow statement shapes get a filesystem check that can later verify or contradict it.',
168
+ parameters: {
169
+ text: {
170
+ type: 'string',
171
+ required: true,
172
+ description: 'The statement to remember, in the user\'s own words. One claim per call.',
173
+ },
174
+ kind: {
175
+ type: 'string',
176
+ enum: [...KINDS],
177
+ description: 'fact (default), decision, preference, or pitfall.',
178
+ },
179
+ subject: {
180
+ type: 'string',
181
+ description: 'What the claim is about. Two different statements with the same subject are reported as a conflict; omit to use the statement itself.',
182
+ },
183
+ scope: {
184
+ type: 'string',
185
+ enum: [...SCOPES],
186
+ description: 'project (default) travels with this checkout; user applies everywhere this user works.',
187
+ },
188
+ },
189
+ output: {
190
+ schema: {
191
+ type: 'object',
192
+ additionalProperties: false,
193
+ properties: {
194
+ id: { type: 'string', required: true, description: 'Reference for the recorded memory.' },
195
+ text: { type: 'string', required: true, description: 'The statement as stored (trimmed).' },
196
+ kind: { type: 'string', required: true, description: 'The kind it was recorded as.' },
197
+ scope: { type: 'string', required: true, description: 'Which log it was written to.' },
198
+ check: { type: 'string', description: 'The filesystem check that will re-verify it. Absent when none could be compiled.' },
199
+ updated: { type: 'boolean', required: true, description: 'True when this statement was already recorded and only its timestamp was refreshed.' },
200
+ },
201
+ },
202
+ render: (_args, value) => [{
203
+ type: 'text',
204
+ text: [
205
+ `${value.updated ? 'Already recorded' : 'Remembered'} ${value.id} [${value.scope}, ${value.kind}]: ${value.text}`,
206
+ value.check === undefined
207
+ ? 'No check could be compiled from this statement, so nothing will re-verify it.'
208
+ : `Check: ${value.check}.`,
209
+ ].join('\n'),
210
+ }],
211
+ },
212
+ // Writes append one line to a file. Two of them commute, and the registry already
213
+ // serializes nothing else about them, so they may share a parallel group.
214
+ isConcurrencySafe: () => true,
215
+ async execute(args, exec) {
216
+ const memory = await options.resolve(exec.agent, exec.signal);
217
+ const kind = (args.kind ?? 'fact');
218
+ const scope = (args.scope ?? 'project');
219
+ const { memory: stored, updated } = memory.record({
220
+ text: args.text,
221
+ kind,
222
+ scope,
223
+ // Provenance is not decoration: the panel distinguishes what the user said from
224
+ // what the model decided to write down, and a memory is only as trustworthy as
225
+ // its source is.
226
+ source: 'agent',
227
+ ...(args.subject === undefined || args.subject.trim().length === 0 ? {} : { subject: args.subject }),
228
+ ...(exec.agent === undefined ? {} : { sessionId: exec.agent.id }),
229
+ });
230
+ return {
231
+ id: stored.id,
232
+ text: stored.text,
233
+ kind: stored.kind,
234
+ scope,
235
+ ...(stored.guard === undefined ? {} : { check: describeGuard(stored.guard) }),
236
+ updated,
237
+ };
238
+ },
239
+ }));
240
+ });
241
+ }