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/identity.d.ts
CHANGED
|
@@ -12,14 +12,25 @@
|
|
|
12
12
|
*
|
|
13
13
|
* @module context-memory/identity
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* Strip the discourse padding in front of a statement.
|
|
17
|
+
*
|
|
18
|
+
* Shared with `compileGuard` on purpose. The subject is what a memory is keyed by and
|
|
19
|
+
* the check is what makes it verifiable; if the two disagreed about where the actual
|
|
20
|
+
* statement starts, "note that the file README.md exists" would get the subject of
|
|
21
|
+
* "file README.md exists" — and no check at all, because the guard patterns are
|
|
22
|
+
* anchored at the start.
|
|
23
|
+
*
|
|
24
|
+
* Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
|
|
25
|
+
* single pass would leave "note that …" behind.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripLeadingFillers(text: string): string;
|
|
15
28
|
/**
|
|
16
29
|
* Reduce a memory to the key it is stored and compared under.
|
|
17
30
|
*
|
|
18
|
-
* Lowercased, punctuation-stripped, whitespace-collapsed, and with
|
|
19
|
-
* dropped, so "The build uses pnpm" and "build uses pnpm" are the same
|
|
20
|
-
*
|
|
21
|
-
* too: those words change how a memory is checked, not what it is about, and keeping
|
|
22
|
-
* them would make the same fact conflict with itself.
|
|
31
|
+
* Lowercased, punctuation-stripped, whitespace-collapsed, and with discourse fillers and
|
|
32
|
+
* a leading article dropped, so "The build uses pnpm" and "build uses pnpm" are the same
|
|
33
|
+
* memory while "never commit secrets" stays a different claim from "commit secrets".
|
|
23
34
|
*/
|
|
24
35
|
export declare function normaliseSubject(text: string): string;
|
|
25
36
|
/**
|
package/lib/identity.js
CHANGED
|
@@ -14,32 +14,50 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Words a statement can carry in front without changing what it claims.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* Deliberately *not* included: `never` and `always`. They read like chatter but they set
|
|
20
|
+
* the polarity and the scope of the claim — "never commit secrets" and "commit secrets"
|
|
21
|
+
* are opposites, so folding them into one subject lets the second overwrite the first
|
|
22
|
+
* without a trace. Discourse fillers ("please", "note that", "remember that") and the
|
|
23
|
+
* leading article carry nothing and are stripped.
|
|
24
24
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
const LEADING_FILLER = /^(?:please|note that|remember that|the)\s+/i;
|
|
26
|
+
/**
|
|
27
|
+
* Strip the discourse padding in front of a statement.
|
|
28
|
+
*
|
|
29
|
+
* Shared with `compileGuard` on purpose. The subject is what a memory is keyed by and
|
|
30
|
+
* the check is what makes it verifiable; if the two disagreed about where the actual
|
|
31
|
+
* statement starts, "note that the file README.md exists" would get the subject of
|
|
32
|
+
* "file README.md exists" — and no check at all, because the guard patterns are
|
|
33
|
+
* anchored at the start.
|
|
34
|
+
*
|
|
35
|
+
* Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
|
|
36
|
+
* single pass would leave "note that …" behind.
|
|
37
|
+
*/
|
|
38
|
+
export function stripLeadingFillers(text) {
|
|
39
|
+
let result = text;
|
|
36
40
|
let previous;
|
|
37
41
|
do {
|
|
38
42
|
previous = result;
|
|
39
|
-
result = result.replace(
|
|
43
|
+
result = result.replace(LEADING_FILLER, '');
|
|
40
44
|
} while (result !== previous);
|
|
41
45
|
return result;
|
|
42
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Reduce a memory to the key it is stored and compared under.
|
|
49
|
+
*
|
|
50
|
+
* Lowercased, punctuation-stripped, whitespace-collapsed, and with discourse fillers and
|
|
51
|
+
* a leading article dropped, so "The build uses pnpm" and "build uses pnpm" are the same
|
|
52
|
+
* memory while "never commit secrets" stays a different claim from "commit secrets".
|
|
53
|
+
*/
|
|
54
|
+
export function normaliseSubject(text) {
|
|
55
|
+
return stripLeadingFillers(text
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[`"'.,;:!?()[\]]/g, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim()).trim();
|
|
60
|
+
}
|
|
43
61
|
/**
|
|
44
62
|
* A stable short id for a memory, derived from its subject *and* its text.
|
|
45
63
|
*
|
package/lib/index.d.ts
CHANGED
|
@@ -9,8 +9,23 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import { ContextMemory, type RememberInput } from './memory.js';
|
|
12
|
-
import type { MemoryKind, MemorySource, PanelPayload, RecallHit, VerifySummary } from './types.js';
|
|
12
|
+
import type { MemoryKind, MemoryScope, MemorySource, PanelPayload, RecallHit, VerifySummary } from './types.js';
|
|
13
13
|
export declare const name = "dsh-context-memory";
|
|
14
|
+
/**
|
|
15
|
+
* What this plugin needs from the host before it can run.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately empty. The only host service it reads is `workingDirectory`, and that
|
|
18
|
+
* one is read opportunistically through `ctx.get`, not declared here.
|
|
19
|
+
*
|
|
20
|
+
* The reason is measurable: `working-directory` is a row in the `dsh-base` bundle's
|
|
21
|
+
* patch, and a profile whose `dsh-base` is older than the running dsh does not have
|
|
22
|
+
* that row — npm reports the mismatch (`@deepseek-ai/dsh-base@0.2.0-rc.2 invalid`) but
|
|
23
|
+
* the host still boots. Declaring the service here makes the whole plugin wait for it:
|
|
24
|
+
* `pending (waiting for service: workingDirectory)` and nothing runs at all, including
|
|
25
|
+
* the CLI-visible memory. Reading it optionally degrades to the session header's `cwd`,
|
|
26
|
+
* which is what the plugin used before it knew about the service.
|
|
27
|
+
*/
|
|
28
|
+
export declare const inject: string[];
|
|
14
29
|
/** Panel-facing feature switches, mirrored by the prefs route. */
|
|
15
30
|
export interface MemoryPrefs {
|
|
16
31
|
autoCapture: boolean;
|
|
@@ -19,14 +34,30 @@ export interface MemoryPrefs {
|
|
|
19
34
|
/** The service shape other plugins and the panel use. */
|
|
20
35
|
export interface MemoryService {
|
|
21
36
|
remember(input: RememberInput): ReturnType<ContextMemory['remember']>;
|
|
22
|
-
forget(id: string, reason: string,
|
|
37
|
+
forget(id: string, reason: string, options?: {
|
|
38
|
+
replacedBy?: string;
|
|
39
|
+
scope?: MemoryScope;
|
|
40
|
+
}): boolean;
|
|
23
41
|
recall(query: string, limit?: number): RecallHit[];
|
|
24
42
|
conflicts(): ReturnType<ContextMemory['conflicts']>;
|
|
25
43
|
verify(): VerifySummary;
|
|
26
44
|
active(): ReturnType<ContextMemory['active']>;
|
|
27
45
|
panel(): PanelPayload;
|
|
46
|
+
/** Statements the capture heuristics noticed but never had permission to keep. */
|
|
47
|
+
candidates(): ReturnType<ContextMemory['candidates']>;
|
|
48
|
+
accept(id: string, overrides?: {
|
|
49
|
+
text?: string;
|
|
50
|
+
kind?: MemoryKind;
|
|
51
|
+
scope?: 'project' | 'user';
|
|
52
|
+
}): ReturnType<ContextMemory['accept']>;
|
|
53
|
+
dismiss(id: string): boolean;
|
|
28
54
|
prefs(): MemoryPrefs;
|
|
29
55
|
setPrefs(patch: Partial<MemoryPrefs>): MemoryPrefs;
|
|
56
|
+
/** Where each scope is stored, so the panel can say which log it is showing. */
|
|
57
|
+
locations(): {
|
|
58
|
+
project: string;
|
|
59
|
+
user: string | undefined;
|
|
60
|
+
};
|
|
30
61
|
}
|
|
31
62
|
export declare function apply(ctx: Context): void;
|
|
32
63
|
export type { MemoryKind, MemorySource };
|
package/lib/index.js
CHANGED
|
@@ -7,24 +7,119 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module context-memory/index
|
|
9
9
|
*/
|
|
10
|
-
import { resolve } from 'node:path';
|
|
11
10
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
12
11
|
import { ContextMemory } from './memory.js';
|
|
13
|
-
import { distill } from './capture.js';
|
|
12
|
+
import { candidates, distill } from './capture.js';
|
|
13
|
+
import { describeContradiction } from './guard.js';
|
|
14
14
|
import { registerMemoryRoutes } from './routes.js';
|
|
15
|
+
import { registerMemoryTools } from './tools.js';
|
|
16
|
+
import { resolveProjectStore, userStoreDir } from './scope.js';
|
|
15
17
|
import { isRecallMessage, recallSource } from './source.js';
|
|
16
18
|
export const name = 'dsh-context-memory';
|
|
19
|
+
/**
|
|
20
|
+
* What this plugin needs from the host before it can run.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately empty. The only host service it reads is `workingDirectory`, and that
|
|
23
|
+
* one is read opportunistically through `ctx.get`, not declared here.
|
|
24
|
+
*
|
|
25
|
+
* The reason is measurable: `working-directory` is a row in the `dsh-base` bundle's
|
|
26
|
+
* patch, and a profile whose `dsh-base` is older than the running dsh does not have
|
|
27
|
+
* that row — npm reports the mismatch (`@deepseek-ai/dsh-base@0.2.0-rc.2 invalid`) but
|
|
28
|
+
* the host still boots. Declaring the service here makes the whole plugin wait for it:
|
|
29
|
+
* `pending (waiting for service: workingDirectory)` and nothing runs at all, including
|
|
30
|
+
* the CLI-visible memory. Reading it optionally degrades to the session header's `cwd`,
|
|
31
|
+
* which is what the plugin used before it knew about the service.
|
|
32
|
+
*/
|
|
33
|
+
export const inject = [];
|
|
34
|
+
/**
|
|
35
|
+
* How often the pre-step re-checks memories, in milliseconds.
|
|
36
|
+
*
|
|
37
|
+
* A day is chosen against the failure it prevents: a memory that quietly stopped being
|
|
38
|
+
* true. Checking less often lets a stale claim survive whole sessions; checking every
|
|
39
|
+
* step spends filesystem work on an answer that cannot have changed between two
|
|
40
|
+
* consecutive prompts.
|
|
41
|
+
*/
|
|
42
|
+
const REVERIFY_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
17
43
|
export function apply(ctx) {
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
// One instance per project root, created on demand. The launch directory is only the
|
|
45
|
+
// starting point: a session may begin below the project root, and its directory may
|
|
46
|
+
// move. The pre-step resolves the session's own directory per request; the panel reads
|
|
47
|
+
// whichever project a step most recently resolved, which is the one the user is in.
|
|
48
|
+
const instances = new Map();
|
|
49
|
+
const memoryFor = (cwd) => {
|
|
50
|
+
const root = resolveProjectStore(cwd);
|
|
51
|
+
const existing = instances.get(root);
|
|
52
|
+
if (existing)
|
|
53
|
+
return existing;
|
|
54
|
+
const created = new ContextMemory(root, { userDir: userStoreDir() });
|
|
55
|
+
instances.set(root, created);
|
|
56
|
+
return created;
|
|
57
|
+
};
|
|
58
|
+
let memory = memoryFor();
|
|
59
|
+
/**
|
|
60
|
+
* The directory a session is working in.
|
|
61
|
+
*
|
|
62
|
+
* The session's own directory, not the launch directory: a session can start below the
|
|
63
|
+
* project root and can be moved mid-life, and both must keep the same memory. `ensure`
|
|
64
|
+
* is the host's authoritative answer (it follows `workingDirectory.set`).
|
|
65
|
+
*
|
|
66
|
+
* Read through `ctx.get`, not `ctx.workingDirectory`: the service is optional here (see
|
|
67
|
+
* `inject` above), and a host without it must fall back rather than refuse to load. The
|
|
68
|
+
* header's original `cwd` is the fallback, and the launch directory the last resort.
|
|
69
|
+
*
|
|
70
|
+
* A silent fallback would be the wrong kind of resilience. If a future `dsh-base` stops
|
|
71
|
+
* mounting the service — a renamed row, a bundle that drops it — every session would
|
|
72
|
+
* quietly resolve to its *initial* cwd, and memories written after a `workingDirectory.set`
|
|
73
|
+
* would land in the wrong project's log with nothing to show it happened. So each
|
|
74
|
+
* degraded path reports itself once per process; `warn` being unavailable is the only
|
|
75
|
+
* thing that may fail silently, because logging must never break a step.
|
|
76
|
+
*/
|
|
77
|
+
let warnedNoService = false;
|
|
78
|
+
let warnedEnsureFailed = false;
|
|
79
|
+
const warn = (message) => {
|
|
80
|
+
try {
|
|
81
|
+
ctx.logger('dsh-context-memory').warn(message);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A Context without a logger service (a bare test context) still gets the fallback.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const cwdFor = async (agent, signal) => {
|
|
88
|
+
if (agent === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const service = ctx.get('workingDirectory');
|
|
92
|
+
if (service === undefined) {
|
|
93
|
+
if (!warnedNoService) {
|
|
94
|
+
warnedNoService = true;
|
|
95
|
+
warn('no workingDirectory service on this host; using the session header cwd. '
|
|
96
|
+
+ 'Memory written after a directory change will be filed under the original directory.');
|
|
97
|
+
}
|
|
98
|
+
return agent.session.header.cwd;
|
|
99
|
+
}
|
|
100
|
+
return (await service.ensure(agent, signal)) ?? agent.session.header.cwd;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (!warnedEnsureFailed) {
|
|
104
|
+
warnedEnsureFailed = true;
|
|
105
|
+
warn(`workingDirectory.ensure failed; using the session header cwd: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
return agent.session.header.cwd;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/** When the last automatic re-check ran, so a day's worth of steps do not each run one. */
|
|
111
|
+
let lastReverifyAt = 0;
|
|
20
112
|
const service = {
|
|
21
113
|
remember: (input) => memory.remember(input),
|
|
22
|
-
forget: (id, reason,
|
|
114
|
+
forget: (id, reason, options) => memory.forget(id, reason, options),
|
|
23
115
|
recall: (query, limit) => memory.recall(query, limit),
|
|
24
116
|
conflicts: () => memory.conflicts(),
|
|
25
117
|
verify: () => memory.verify(),
|
|
26
118
|
active: () => memory.active(),
|
|
27
119
|
panel: () => memory.panel(),
|
|
120
|
+
candidates: () => memory.candidates(),
|
|
121
|
+
accept: (id, overrides) => memory.accept(id, overrides),
|
|
122
|
+
dismiss: (id) => memory.dismiss(id),
|
|
28
123
|
prefs: () => ({ autoCapture: captureEnabled, recallInject: injectEnabled }),
|
|
29
124
|
setPrefs: (patch) => {
|
|
30
125
|
if (typeof patch.autoCapture === 'boolean')
|
|
@@ -33,9 +128,23 @@ export function apply(ctx) {
|
|
|
33
128
|
injectEnabled = patch.recallInject;
|
|
34
129
|
return { autoCapture: captureEnabled, recallInject: injectEnabled };
|
|
35
130
|
},
|
|
131
|
+
locations: () => memory.locations,
|
|
36
132
|
};
|
|
37
133
|
ctx.provide('contextMemory', service);
|
|
38
134
|
registerMemoryRoutes(ctx, service);
|
|
135
|
+
// The pull half. Registered through `ctx.inject(['tools'])` inside, so a host with no
|
|
136
|
+
// tool registry (the CLI, a headless profile) loses the two tools and nothing else.
|
|
137
|
+
registerMemoryTools(ctx, {
|
|
138
|
+
// A tool call belongs to the agent that issued it, and an agent belongs to a session
|
|
139
|
+
// with its own directory. Answering with whatever the last pre-step resolved would
|
|
140
|
+
// let one project's question be answered from another project's log whenever two
|
|
141
|
+
// sessions are open — so the instance is resolved per call, from the caller's own
|
|
142
|
+
// directory, and the pre-step's "current" instance is not consulted at all.
|
|
143
|
+
resolve: async (agent, signal) => {
|
|
144
|
+
const cwd = await cwdFor(agent, signal);
|
|
145
|
+
return memoryFor(cwd);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
39
148
|
// ─── Recall injection + auto capture (durable plugin context) ─
|
|
40
149
|
//
|
|
41
150
|
// Before each step the active memories whose subject overlaps the incoming
|
|
@@ -53,7 +162,7 @@ export function apply(ctx) {
|
|
|
53
162
|
let injectEnabled = true;
|
|
54
163
|
let captureEnabled = true;
|
|
55
164
|
let injectedMessageIds = [];
|
|
56
|
-
const contextMessageFor = (query) => {
|
|
165
|
+
const contextMessageFor = (memory, query) => {
|
|
57
166
|
const hits = memory.recall(query, 5);
|
|
58
167
|
const lines = [];
|
|
59
168
|
if (hits.length > 0) {
|
|
@@ -61,12 +170,33 @@ export function apply(ctx) {
|
|
|
61
170
|
for (const hit of hits)
|
|
62
171
|
lines.push(`- [${hit.memory.kind}] ${hit.memory.text}`);
|
|
63
172
|
}
|
|
173
|
+
// Contradicted memories are shown as a problem, never as guidance. Silently dropping
|
|
174
|
+
// them would hide the fact that the log now contains a claim the world denies; the
|
|
175
|
+
// model can resolve it (and should), while a user reading the transcript learns that
|
|
176
|
+
// something needs superseding.
|
|
177
|
+
const contradicted = memory.contradicted().slice(0, 3);
|
|
178
|
+
if (contradicted.length > 0) {
|
|
179
|
+
if (lines.length > 0)
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('Memories a check has contradicted (do not rely on these; correct or supersede them):');
|
|
182
|
+
for (const item of contradicted) {
|
|
183
|
+
// A check that ran and came back false reports no output of its own, so the
|
|
184
|
+
// fallback names the operand that no longer matches. Without it the line reads
|
|
185
|
+
// "contradicted" with no way to tell what was tested.
|
|
186
|
+
lines.push(`- [${item.kind}] ${item.text} ← ${describeContradiction(item.guard, item.verification?.output)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
64
189
|
const conflicts = memory.conflicts();
|
|
65
190
|
if (conflicts.length > 0) {
|
|
66
|
-
lines.
|
|
191
|
+
if (lines.length > 0)
|
|
192
|
+
lines.push('');
|
|
67
193
|
lines.push('Unresolved memory conflicts (verify before relying on either side):');
|
|
194
|
+
// Both sides are shown, not just the count: a conflict the reader cannot see the
|
|
195
|
+
// two texts of is a question they cannot answer.
|
|
68
196
|
for (const conflict of conflicts.slice(0, 3)) {
|
|
69
|
-
lines.push(`- Subject "${conflict.subject}" has ${conflict.memories.length} disagreeing records
|
|
197
|
+
lines.push(`- Subject "${conflict.subject}" has ${conflict.memories.length} disagreeing records:`);
|
|
198
|
+
for (const item of conflict.memories)
|
|
199
|
+
lines.push(` · ${item.id} ${item.text}`);
|
|
70
200
|
}
|
|
71
201
|
}
|
|
72
202
|
return createUserMessage({
|
|
@@ -99,18 +229,50 @@ export function apply(ctx) {
|
|
|
99
229
|
const trimmed = prompt.trim();
|
|
100
230
|
if (trimmed.length === 0)
|
|
101
231
|
return decision;
|
|
232
|
+
// A subagent runs in its own session with its own header; it must not inherit the
|
|
233
|
+
// parent's project just because it shares a process. Sessions with no declared origin
|
|
234
|
+
// are ordinary user sessions.
|
|
235
|
+
const origin = agent.session.header.origin;
|
|
236
|
+
if (origin === 'subagent')
|
|
237
|
+
return decision;
|
|
238
|
+
// The session's own directory, not the launch directory: a session can start below
|
|
239
|
+
// the project root and can be moved mid-life, and both must keep the same memory.
|
|
240
|
+
memory = memoryFor(await cwdFor(agent, signal));
|
|
241
|
+
// Re-check before composing, so a memory the world has since disproved is never
|
|
242
|
+
// offered as guidance in the very step that would have used it. Throttled: the
|
|
243
|
+
// answer cannot change between two prompts, and the guards are filesystem reads
|
|
244
|
+
// that should not run on every step.
|
|
245
|
+
if (Date.now() - lastReverifyAt >= REVERIFY_INTERVAL_MS) {
|
|
246
|
+
lastReverifyAt = Date.now();
|
|
247
|
+
try {
|
|
248
|
+
memory.verify(Date.now(), { record: true, changedOnly: true });
|
|
249
|
+
}
|
|
250
|
+
catch { /* a check that cannot be persisted must not stop the step */ }
|
|
251
|
+
}
|
|
102
252
|
// Auto capture FIRST (see comment above): explicit "记住…" statements from
|
|
103
253
|
// this very request are persisted with session provenance, then excluded
|
|
104
254
|
// from the recall composed below.
|
|
105
255
|
if (captureEnabled) {
|
|
106
|
-
|
|
256
|
+
const distilled = distill(trimmed, agent.id);
|
|
257
|
+
for (const statement of distilled) {
|
|
107
258
|
try {
|
|
108
|
-
memory.remember(
|
|
259
|
+
memory.remember(statement);
|
|
109
260
|
}
|
|
110
261
|
catch { /* empty statement cannot occur here */ }
|
|
111
262
|
}
|
|
263
|
+
// Then the wide net, which does not write: a declarative sentence that looks like a
|
|
264
|
+
// durable fact becomes a question in the panel. Keeping the two apart is the whole
|
|
265
|
+
// point of P5 — the explicit rule is what the user asked for, the implicit one is
|
|
266
|
+
// only what the plugin noticed, and conflating them is how a memory log fills with
|
|
267
|
+
// things nobody wanted.
|
|
268
|
+
for (const candidate of candidates(trimmed, agent.id, distilled.map((statement) => statement.text))) {
|
|
269
|
+
try {
|
|
270
|
+
memory.propose(candidate);
|
|
271
|
+
}
|
|
272
|
+
catch { /* a queue that cannot be written must not stop the step */ }
|
|
273
|
+
}
|
|
112
274
|
}
|
|
113
|
-
const desired = contextMessageFor(trimmed);
|
|
275
|
+
const desired = contextMessageFor(memory, trimmed);
|
|
114
276
|
// Nothing relevant and nothing conflicting → empty content: skip entirely
|
|
115
277
|
// rather than ship an empty row into the transcript.
|
|
116
278
|
if (desired.content.length === 0)
|
package/lib/memory.d.ts
CHANGED
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
* what is remembered, and a cache would let one process's view of history outlive the
|
|
7
7
|
* events that contradict it.
|
|
8
8
|
*
|
|
9
|
+
* There are two logs, one per scope. They are folded together rather than merged on
|
|
10
|
+
* disk: a project convention and a working preference are different statements kept in
|
|
11
|
+
* different places for different reasons, and collapsing them would make "this checkout"
|
|
12
|
+
* and "everywhere" the same claim. Which log an event is appended to follows the
|
|
13
|
+
* memory's own scope, never the caller's convenience.
|
|
14
|
+
*
|
|
9
15
|
* @module context-memory/memory
|
|
10
16
|
*/
|
|
11
|
-
import
|
|
17
|
+
import { type ProposeInput } from './candidates.js';
|
|
18
|
+
import type { Candidate, CandidateState, Conflict, Memory, MemoryKind, MemoryScope, MemorySource, MemoryState, MemoryTarget, PanelPayload, RecallHit, VerifySummary } from './types.js';
|
|
12
19
|
export interface RememberInput {
|
|
13
20
|
text: string;
|
|
14
21
|
/**
|
|
@@ -18,25 +25,71 @@ export interface RememberInput {
|
|
|
18
25
|
subject?: string;
|
|
19
26
|
kind?: MemoryKind;
|
|
20
27
|
source?: MemorySource;
|
|
28
|
+
/** Which log to write. Defaults to `project` — the narrow claim is the safe default. */
|
|
29
|
+
scope?: MemoryScope;
|
|
21
30
|
sessionId?: string;
|
|
22
31
|
step?: number;
|
|
23
32
|
}
|
|
33
|
+
/** Construction options. */
|
|
34
|
+
export interface ContextMemoryOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Directory of the user-global log.
|
|
37
|
+
*
|
|
38
|
+
* Omitted means the instance is project-only: `all()` folds one log, and asking to
|
|
39
|
+
* remember something user-global throws rather than quietly writing it to the project.
|
|
40
|
+
* Tests rely on that — an omitted directory must never reach the real home.
|
|
41
|
+
*/
|
|
42
|
+
userDir?: string;
|
|
43
|
+
}
|
|
24
44
|
export declare class ContextMemory {
|
|
25
45
|
private readonly projectDir;
|
|
26
|
-
private readonly
|
|
27
|
-
|
|
46
|
+
private readonly projectStore;
|
|
47
|
+
private readonly userStore;
|
|
48
|
+
private readonly queue;
|
|
49
|
+
/**
|
|
50
|
+
* The last fold, keyed by the identity of both log files.
|
|
51
|
+
*
|
|
52
|
+
* The pre-step asks the same question several times in one pass — who is relevant,
|
|
53
|
+
* who is contradicted, who disagrees — and each answer used to re-fold both logs. The
|
|
54
|
+
* fold is still recomputed whenever either file changes, so two processes reading one
|
|
55
|
+
* log still agree: everything below re-derives from the files, this only avoids
|
|
56
|
+
* deriving the same state from the same bytes more than once.
|
|
57
|
+
*/
|
|
58
|
+
private folded;
|
|
59
|
+
constructor(projectDir: string, options?: ContextMemoryOptions);
|
|
60
|
+
/** Where each scope is written, for the panel and the CLI. */
|
|
61
|
+
get locations(): {
|
|
62
|
+
project: string;
|
|
63
|
+
user: string | undefined;
|
|
64
|
+
};
|
|
65
|
+
/** The project log's path. Kept for callers that only know about the project scope. */
|
|
28
66
|
get logPath(): string;
|
|
67
|
+
private storeFor;
|
|
29
68
|
/**
|
|
30
|
-
* Fold the
|
|
69
|
+
* Fold the logs into current state.
|
|
31
70
|
*
|
|
32
71
|
* Events are applied in log order and a memory is always addressable by id, so a
|
|
33
72
|
* supersede or verify that names a memory not yet seen is skipped rather than
|
|
34
73
|
* creating a phantom entry — a log that lost its `remember` line must not resurrect
|
|
35
74
|
* the memory from its consequences.
|
|
75
|
+
*
|
|
76
|
+
* A memory's scope is part of its identity here: the same sentence can legitimately
|
|
77
|
+
* exist in both logs, and they are two claims about two different domains, so both
|
|
78
|
+
* are kept rather than one shadowing the other.
|
|
36
79
|
*/
|
|
37
80
|
all(): MemoryState[];
|
|
81
|
+
private scopes;
|
|
38
82
|
active(): MemoryState[];
|
|
39
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Active memories no check has contradicted — the population recall ranks and the
|
|
85
|
+
* pre-step may offer the model.
|
|
86
|
+
*/
|
|
87
|
+
guidance(): MemoryState[];
|
|
88
|
+
/** Active memories a check ran against and failed — visible, never offered. */
|
|
89
|
+
contradicted(): MemoryState[];
|
|
90
|
+
get(id: string, scope?: MemoryScope): MemoryState | undefined;
|
|
91
|
+
/** Whether a memory with this id exists in either scope — the candidate queue's dedupe. */
|
|
92
|
+
has(id: string): boolean;
|
|
40
93
|
/**
|
|
41
94
|
* Record a memory.
|
|
42
95
|
*
|
|
@@ -51,6 +104,19 @@ export declare class ContextMemory {
|
|
|
51
104
|
* command would be an injection path.
|
|
52
105
|
*/
|
|
53
106
|
remember(input: RememberInput, now?: number): Memory;
|
|
107
|
+
/**
|
|
108
|
+
* `remember`, plus whether the statement was already on file.
|
|
109
|
+
*
|
|
110
|
+
* The distinction exists for the model-facing tool: "recorded" and "already knew that"
|
|
111
|
+
* are different answers, and a caller reporting the first when the second happened is
|
|
112
|
+
* claiming work it did not do. It is computed here rather than by the caller because
|
|
113
|
+
* the id is derived from the subject and text, and a second copy of that derivation
|
|
114
|
+
* would drift from this one.
|
|
115
|
+
*/
|
|
116
|
+
record(input: RememberInput, now?: number): {
|
|
117
|
+
memory: Memory;
|
|
118
|
+
updated: boolean;
|
|
119
|
+
};
|
|
54
120
|
/**
|
|
55
121
|
* Retire a memory, with a reason.
|
|
56
122
|
*
|
|
@@ -59,7 +125,11 @@ export declare class ContextMemory {
|
|
|
59
125
|
* marked superseded and kept, and the reason is required because "why did this stop
|
|
60
126
|
* being true" is the question that will be asked later.
|
|
61
127
|
*/
|
|
62
|
-
forget(id: string, reason: string,
|
|
128
|
+
forget(id: string, reason: string, options?: {
|
|
129
|
+
replacedBy?: string;
|
|
130
|
+
scope?: MemoryScope;
|
|
131
|
+
now?: number;
|
|
132
|
+
}): boolean;
|
|
63
133
|
/**
|
|
64
134
|
* Group active memories that share a subject but say different things.
|
|
65
135
|
*
|
|
@@ -67,10 +137,53 @@ export declare class ContextMemory {
|
|
|
67
137
|
* and different text means one of them is wrong and the log does not say which; a
|
|
68
138
|
* plausible-looking choice would be exactly the unfounded assertion this plugin exists
|
|
69
139
|
* to eliminate.
|
|
140
|
+
*
|
|
141
|
+
* A contradicted memory is left out: it is not an open question, the world already
|
|
142
|
+
* answered it. Keeping it here would present a settled matter as undecided.
|
|
70
143
|
*/
|
|
71
144
|
conflicts(): Conflict[];
|
|
72
|
-
/**
|
|
73
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Rank memories against a query.
|
|
147
|
+
*
|
|
148
|
+
* Defaults to guidance — the population the pre-step may offer. The pull tool widens
|
|
149
|
+
* the target explicitly when the model asks what is *recorded* rather than what is
|
|
150
|
+
* *true*, and that difference is carried by the argument instead of by a second
|
|
151
|
+
* implementation.
|
|
152
|
+
*/
|
|
153
|
+
recall(query: string, limit?: number, now?: number, target?: MemoryTarget): RecallHit[];
|
|
154
|
+
/**
|
|
155
|
+
* The newest memories in one population, for a caller that asked without a query.
|
|
156
|
+
*
|
|
157
|
+
* "What do you remember" has no lexical content to rank, and answering it with an
|
|
158
|
+
* empty result would be pedantically correct and useless. Newest-first is the one
|
|
159
|
+
* ordering that needs no claim about relevance — it is simply the log's own order,
|
|
160
|
+
* read backwards.
|
|
161
|
+
*/
|
|
162
|
+
recent(limit?: number, target?: MemoryTarget): MemoryState[];
|
|
163
|
+
/**
|
|
164
|
+
* Offer a statement.
|
|
165
|
+
*
|
|
166
|
+
* Returns undefined when it is already settled — an active memory, an open candidate,
|
|
167
|
+
* or one already dismissed. That check is against the *log*, not against the message,
|
|
168
|
+
* so a sentence repeated across a session is asked once rather than on every step.
|
|
169
|
+
*/
|
|
170
|
+
propose(input: ProposeInput): Candidate | undefined;
|
|
171
|
+
candidates(): CandidateState[];
|
|
172
|
+
dismiss(id: string, now?: number): boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Turn a candidate into a memory.
|
|
175
|
+
*
|
|
176
|
+
* The decision is recorded *before* the memory is written, so a write that throws
|
|
177
|
+
* leaves a dismissed-looking proposal rather than a candidate that reappears beside a
|
|
178
|
+
* memory it duplicates. The candidate keeps the kind and provenance it was proposed
|
|
179
|
+
* with, and the user's acceptance is the reason it exists — which is why the memory is
|
|
180
|
+
* written with `source: 'user'`: a person said yes to it.
|
|
181
|
+
*/
|
|
182
|
+
accept(id: string, overrides?: {
|
|
183
|
+
text?: string;
|
|
184
|
+
kind?: MemoryKind;
|
|
185
|
+
scope?: MemoryScope;
|
|
186
|
+
}, now?: number): Memory | undefined;
|
|
74
187
|
/**
|
|
75
188
|
* Re-run every compiled guard.
|
|
76
189
|
*
|
|
@@ -78,8 +191,17 @@ export declare class ContextMemory {
|
|
|
78
191
|
* disagreeing with a memory, `broken` is the check itself being unusable, and
|
|
79
192
|
* `uncheckable` is there being no check. Only the first is evidence about the memory,
|
|
80
193
|
* so only the first may influence a caller's exit code.
|
|
194
|
+
*
|
|
195
|
+
* @param options.record - when false, results are computed but not appended. The
|
|
196
|
+
* pre-step re-checks often; a log entry per check per step would grow the file
|
|
197
|
+
* without saying anything new.
|
|
198
|
+
* @param options.changedOnly - append only when the outcome differs from the last
|
|
199
|
+
* recorded one, so a memory that keeps passing does not add a line per run.
|
|
81
200
|
*/
|
|
82
|
-
verify(now?: number
|
|
201
|
+
verify(now?: number, options?: {
|
|
202
|
+
record?: boolean;
|
|
203
|
+
changedOnly?: boolean;
|
|
204
|
+
}): VerifySummary;
|
|
83
205
|
/**
|
|
84
206
|
* The exit code a caller should use.
|
|
85
207
|
*
|