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/CHANGELOG.md +324 -0
- package/README.md +151 -24
- package/icon.svg +16 -0
- package/lib/candidates.d.ts +71 -0
- package/lib/candidates.js +150 -0
- package/lib/capture.d.ts +21 -0
- package/lib/capture.js +86 -0
- package/lib/cli.js +88 -9
- package/lib/client/index.d.ts +17 -23
- package/lib/client/locales.d.ts +11 -0
- package/lib/client/locales.js +22 -0
- package/lib/client/view.d.ts +8 -1
- package/lib/client/view.js +18 -3
- package/lib/guard.d.ts +92 -30
- package/lib/guard.js +539 -73
- package/lib/identity.d.ts +16 -5
- package/lib/identity.js +36 -18
- package/lib/index.d.ts +33 -2
- package/lib/index.js +173 -11
- package/lib/memory.d.ts +131 -9
- package/lib/memory.js +296 -46
- package/lib/memory.web.js +58 -7
- package/lib/memory.web.js.map +2 -2
- package/lib/recall.d.ts +46 -7
- package/lib/recall.js +119 -12
- package/lib/routes.d.ts +1 -0
- package/lib/routes.js +50 -1
- package/lib/scope.d.ts +45 -0
- package/lib/scope.js +75 -0
- package/lib/store.d.ts +25 -2
- package/lib/store.js +45 -7
- package/lib/tools.d.ts +44 -0
- package/lib/tools.js +241 -0
- package/lib/types.d.ts +182 -3
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -14
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
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
+
}
|