dsh-context-memory 0.7.1 → 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 +312 -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/locales.d.ts +6 -0
- package/lib/client/locales.js +12 -0
- package/lib/client/view.d.ts +2 -0
- 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 +48 -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 -18
package/lib/memory.js
CHANGED
|
@@ -6,56 +6,173 @@
|
|
|
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 { join } from 'node:path';
|
|
18
|
+
import { CandidateQueue } from './candidates.js';
|
|
19
|
+
import { compileGuard, GUARD_KINDS, runGuard } from './guard.js';
|
|
12
20
|
import { memoryId, normaliseSubject } from './identity.js';
|
|
13
|
-
import { recall as rankRecall } from './recall.js';
|
|
21
|
+
import { recall as rankRecall, inTarget, isContradicted } from './recall.js';
|
|
14
22
|
import { MemoryStore } from './store.js';
|
|
23
|
+
/**
|
|
24
|
+
* Accept only a guard this version can evaluate.
|
|
25
|
+
*
|
|
26
|
+
* Logs written before 0.8.0 recorded guards as `{ command: '<shell string>' }`. Those
|
|
27
|
+
* are dropped rather than read: the field no longer means what it did, and a memory
|
|
28
|
+
* evaluated through a stale shape would be answered by whichever predicate branch
|
|
29
|
+
* happened to match its `undefined` kind. Dropping it makes the memory `uncheckable`,
|
|
30
|
+
* which is true — this version cannot carry out that check — and leaves the record
|
|
31
|
+
* intact for a reader that can.
|
|
32
|
+
*/
|
|
33
|
+
function usableGuard(guard) {
|
|
34
|
+
if (guard === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const kind = guard.kind;
|
|
37
|
+
// A whitelist, not a denylist: a guard written by a newer version must be dropped here
|
|
38
|
+
// rather than reach `runGuard`, whose branches would answer it with whichever predicate
|
|
39
|
+
// their fallthrough happens to be. The list lives in `guard.ts`, next to the union it
|
|
40
|
+
// mirrors, so adding a kind is one edit and forgetting it is a compile error.
|
|
41
|
+
if (typeof kind === 'string' && GUARD_KINDS.includes(kind))
|
|
42
|
+
return guard;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The identity a memory is addressed by inside this process. */
|
|
46
|
+
function keyOf(scope, id) {
|
|
47
|
+
return `${scope}\u0000${id}`;
|
|
48
|
+
}
|
|
15
49
|
export class ContextMemory {
|
|
16
50
|
projectDir;
|
|
17
|
-
|
|
18
|
-
|
|
51
|
+
projectStore;
|
|
52
|
+
userStore;
|
|
53
|
+
queue;
|
|
54
|
+
/**
|
|
55
|
+
* The last fold, keyed by the identity of both log files.
|
|
56
|
+
*
|
|
57
|
+
* The pre-step asks the same question several times in one pass — who is relevant,
|
|
58
|
+
* who is contradicted, who disagrees — and each answer used to re-fold both logs. The
|
|
59
|
+
* fold is still recomputed whenever either file changes, so two processes reading one
|
|
60
|
+
* log still agree: everything below re-derives from the files, this only avoids
|
|
61
|
+
* deriving the same state from the same bytes more than once.
|
|
62
|
+
*/
|
|
63
|
+
folded;
|
|
64
|
+
constructor(projectDir, options = {}) {
|
|
19
65
|
this.projectDir = projectDir;
|
|
20
|
-
this.
|
|
66
|
+
this.projectStore = new MemoryStore(join(projectDir, '.memory'));
|
|
67
|
+
this.userStore = options.userDir === undefined ? undefined : new MemoryStore(options.userDir);
|
|
68
|
+
// The queue is per project, beside the project log: a statement noticed while working
|
|
69
|
+
// in one checkout is a question about that checkout, and offering it in another
|
|
70
|
+
// project would be offering it out of context.
|
|
71
|
+
this.queue = new CandidateQueue(join(projectDir, '.memory'));
|
|
72
|
+
}
|
|
73
|
+
/** Where each scope is written, for the panel and the CLI. */
|
|
74
|
+
get locations() {
|
|
75
|
+
return { project: this.projectStore.path, user: this.userStore?.path };
|
|
21
76
|
}
|
|
77
|
+
/** The project log's path. Kept for callers that only know about the project scope. */
|
|
22
78
|
get logPath() {
|
|
23
|
-
return this.
|
|
79
|
+
return this.projectStore.path;
|
|
80
|
+
}
|
|
81
|
+
storeFor(scope) {
|
|
82
|
+
if (scope === 'project')
|
|
83
|
+
return this.projectStore;
|
|
84
|
+
if (this.userStore === undefined) {
|
|
85
|
+
throw new Error('this instance has no user-global log; construct it with a userDir');
|
|
86
|
+
}
|
|
87
|
+
return this.userStore;
|
|
24
88
|
}
|
|
25
89
|
/**
|
|
26
|
-
* Fold the
|
|
90
|
+
* Fold the logs into current state.
|
|
27
91
|
*
|
|
28
92
|
* Events are applied in log order and a memory is always addressable by id, so a
|
|
29
93
|
* supersede or verify that names a memory not yet seen is skipped rather than
|
|
30
94
|
* creating a phantom entry — a log that lost its `remember` line must not resurrect
|
|
31
95
|
* the memory from its consequences.
|
|
96
|
+
*
|
|
97
|
+
* A memory's scope is part of its identity here: the same sentence can legitimately
|
|
98
|
+
* exist in both logs, and they are two claims about two different domains, so both
|
|
99
|
+
* are kept rather than one shadowing the other.
|
|
32
100
|
*/
|
|
33
101
|
all() {
|
|
102
|
+
// Both files' identities, so an append by another process invalidates this exactly
|
|
103
|
+
// as an append by this one does.
|
|
104
|
+
const key = this.scopes()
|
|
105
|
+
.map((scope) => `${scope}=${this.storeFor(scope).signature() ?? '-'}`)
|
|
106
|
+
.join('|');
|
|
107
|
+
if (this.folded !== undefined && this.folded.key === key)
|
|
108
|
+
return this.folded.value;
|
|
34
109
|
const byId = new Map();
|
|
35
|
-
for (const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
110
|
+
for (const scope of this.scopes()) {
|
|
111
|
+
const store = this.storeFor(scope);
|
|
112
|
+
for (const event of store.readAll()) {
|
|
113
|
+
if (event.type === 'remember') {
|
|
114
|
+
const recorded = event.memory;
|
|
115
|
+
const guard = usableGuard(recorded.guard);
|
|
116
|
+
// Rebuild rather than spread-and-overwrite: with exactOptionalPropertyTypes a
|
|
117
|
+
// spread carrying `guard: undefined` is not the same type as omitting it, and
|
|
118
|
+
// the log's own field must not survive as an unevaluable shape. The recorded
|
|
119
|
+
// `subject` is kept as written rather than re-derived from the text: it may
|
|
120
|
+
// have been supplied explicitly, and recomputing it here would quietly discard
|
|
121
|
+
// that choice.
|
|
122
|
+
const { guard: _dropped, scope: _recordedScope, ...rest } = recorded;
|
|
123
|
+
byId.set(keyOf(scope, recorded.id), {
|
|
124
|
+
...rest,
|
|
125
|
+
// A log's own scope field cannot override the log's location: events are
|
|
126
|
+
// appended to the store that owns them, so the location is authoritative.
|
|
127
|
+
scope,
|
|
128
|
+
...(guard ? { guard } : {}),
|
|
129
|
+
status: 'active',
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const target = byId.get(keyOf(scope, event.id));
|
|
134
|
+
if (!target)
|
|
135
|
+
continue;
|
|
136
|
+
if (event.type === 'supersede') {
|
|
137
|
+
target.status = 'superseded';
|
|
138
|
+
target.supersededReason = event.reason;
|
|
139
|
+
if (event.replacedBy !== undefined)
|
|
140
|
+
target.supersededBy = event.replacedBy;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
target.verification = { at: event.at, outcome: event.outcome, exitCode: event.exitCode, output: event.output };
|
|
49
144
|
}
|
|
50
|
-
target.verification = { at: event.at, outcome: event.outcome, exitCode: event.exitCode, output: event.output };
|
|
51
145
|
}
|
|
52
|
-
|
|
146
|
+
const value = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
147
|
+
this.folded = { key, value };
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
scopes() {
|
|
151
|
+
return this.userStore === undefined ? ['project'] : ['project', 'user'];
|
|
53
152
|
}
|
|
54
153
|
active() {
|
|
55
154
|
return this.all().filter((memory) => memory.status === 'active');
|
|
56
155
|
}
|
|
57
|
-
|
|
58
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Active memories no check has contradicted — the population recall ranks and the
|
|
158
|
+
* pre-step may offer the model.
|
|
159
|
+
*/
|
|
160
|
+
guidance() {
|
|
161
|
+
return this.active().filter((memory) => !isContradicted(memory));
|
|
162
|
+
}
|
|
163
|
+
/** Active memories a check ran against and failed — visible, never offered. */
|
|
164
|
+
contradicted() {
|
|
165
|
+
return this.active().filter(isContradicted);
|
|
166
|
+
}
|
|
167
|
+
get(id, scope) {
|
|
168
|
+
const all = this.all();
|
|
169
|
+
if (scope !== undefined)
|
|
170
|
+
return all.find((memory) => memory.id === id && memory.scope === scope);
|
|
171
|
+
return all.find((memory) => memory.id === id);
|
|
172
|
+
}
|
|
173
|
+
/** Whether a memory with this id exists in either scope — the candidate queue's dedupe. */
|
|
174
|
+
has(id) {
|
|
175
|
+
return this.get(id) !== undefined;
|
|
59
176
|
}
|
|
60
177
|
/**
|
|
61
178
|
* Record a memory.
|
|
@@ -71,28 +188,45 @@ export class ContextMemory {
|
|
|
71
188
|
* command would be an injection path.
|
|
72
189
|
*/
|
|
73
190
|
remember(input, now = Date.now()) {
|
|
191
|
+
return this.record(input, now).memory;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* `remember`, plus whether the statement was already on file.
|
|
195
|
+
*
|
|
196
|
+
* The distinction exists for the model-facing tool: "recorded" and "already knew that"
|
|
197
|
+
* are different answers, and a caller reporting the first when the second happened is
|
|
198
|
+
* claiming work it did not do. It is computed here rather than by the caller because
|
|
199
|
+
* the id is derived from the subject and text, and a second copy of that derivation
|
|
200
|
+
* would drift from this one.
|
|
201
|
+
*/
|
|
202
|
+
record(input, now = Date.now()) {
|
|
74
203
|
const text = input.text.trim();
|
|
75
204
|
if (text.length === 0)
|
|
76
205
|
throw new Error('cannot remember an empty statement');
|
|
206
|
+
const scope = input.scope ?? 'project';
|
|
207
|
+
const store = this.storeFor(scope);
|
|
77
208
|
const subject = input.subject !== undefined && input.subject.trim().length > 0
|
|
78
209
|
? normaliseSubject(input.subject)
|
|
79
210
|
: normaliseSubject(text);
|
|
80
211
|
const id = memoryId(subject, text);
|
|
81
212
|
const guard = compileGuard(text);
|
|
82
|
-
|
|
213
|
+
// The id deliberately excludes the scope: the same sentence recorded in both logs is
|
|
214
|
+
// the same sentence, and a panel that showed two unrelated hex strings for it would
|
|
215
|
+
// hide the fact that one statement was promoted from a project to a preference.
|
|
216
|
+
const memory = { id, subject, text, kind: input.kind ?? 'fact', source: input.source ?? 'user', scope, createdAt: now };
|
|
83
217
|
if (input.sessionId !== undefined)
|
|
84
218
|
memory.sessionId = input.sessionId;
|
|
85
219
|
if (input.step !== undefined)
|
|
86
220
|
memory.step = input.step;
|
|
87
221
|
if (guard)
|
|
88
222
|
memory.guard = guard;
|
|
89
|
-
const existing = this.get(id);
|
|
223
|
+
const existing = this.get(id, scope);
|
|
90
224
|
// Keep the original creation time: re-stating a memory is not the memory being new,
|
|
91
225
|
// and resetting it would let any repeat silently outrank older, better-evidenced ones.
|
|
92
226
|
if (existing)
|
|
93
227
|
memory.createdAt = existing.createdAt;
|
|
94
|
-
|
|
95
|
-
return memory;
|
|
228
|
+
store.append({ type: 'remember', at: now, memory });
|
|
229
|
+
return { memory, updated: existing !== undefined };
|
|
96
230
|
}
|
|
97
231
|
/**
|
|
98
232
|
* Retire a memory, with a reason.
|
|
@@ -102,15 +236,20 @@ export class ContextMemory {
|
|
|
102
236
|
* marked superseded and kept, and the reason is required because "why did this stop
|
|
103
237
|
* being true" is the question that will be asked later.
|
|
104
238
|
*/
|
|
105
|
-
forget(id, reason,
|
|
106
|
-
|
|
239
|
+
forget(id, reason, options = {}) {
|
|
240
|
+
// The id does not carry a scope, so the same sentence held in both logs is two rows
|
|
241
|
+
// with one id. Without an explicit scope the choice between them would be whichever
|
|
242
|
+
// the fold happened to list first — a coin flip presented as a decision. Callers that
|
|
243
|
+
// show both rows (the panel) pass the scope they were looking at.
|
|
244
|
+
const target = this.get(id, options.scope);
|
|
245
|
+
if (!target)
|
|
107
246
|
return false;
|
|
108
247
|
if (reason.trim().length === 0)
|
|
109
248
|
throw new Error('a memory can only be superseded with a reason');
|
|
110
|
-
const event = { type: 'supersede', at: now, id, reason: reason.trim() };
|
|
111
|
-
if (replacedBy !== undefined)
|
|
112
|
-
event.replacedBy = replacedBy;
|
|
113
|
-
this.
|
|
249
|
+
const event = { type: 'supersede', at: options.now ?? Date.now(), id, reason: reason.trim() };
|
|
250
|
+
if (options.replacedBy !== undefined)
|
|
251
|
+
event.replacedBy = options.replacedBy;
|
|
252
|
+
this.storeFor(target.scope).append(event);
|
|
114
253
|
return true;
|
|
115
254
|
}
|
|
116
255
|
/**
|
|
@@ -120,10 +259,13 @@ export class ContextMemory {
|
|
|
120
259
|
* and different text means one of them is wrong and the log does not say which; a
|
|
121
260
|
* plausible-looking choice would be exactly the unfounded assertion this plugin exists
|
|
122
261
|
* to eliminate.
|
|
262
|
+
*
|
|
263
|
+
* A contradicted memory is left out: it is not an open question, the world already
|
|
264
|
+
* answered it. Keeping it here would present a settled matter as undecided.
|
|
123
265
|
*/
|
|
124
266
|
conflicts() {
|
|
125
267
|
const groups = new Map();
|
|
126
|
-
for (const memory of this.
|
|
268
|
+
for (const memory of this.guidance()) {
|
|
127
269
|
const group = groups.get(memory.subject);
|
|
128
270
|
if (group)
|
|
129
271
|
group.push(memory);
|
|
@@ -132,14 +274,89 @@ export class ContextMemory {
|
|
|
132
274
|
}
|
|
133
275
|
const conflicts = [];
|
|
134
276
|
for (const [subject, memories] of groups) {
|
|
135
|
-
|
|
136
|
-
|
|
277
|
+
// Disagreement means a *different claim*, and a claim is identified by its id. The
|
|
278
|
+
// same sentence can legitimately be recorded in both logs; it then appears twice
|
|
279
|
+
// with the same id, which is one statement held twice, not a question to answer.
|
|
280
|
+
// Without this, recording one convention per scope would report a memory as
|
|
281
|
+
// conflicting with itself and offer "resolve" for a choice that does not exist.
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
const distinct = memories.filter((memory) => {
|
|
284
|
+
if (seen.has(memory.id))
|
|
285
|
+
return false;
|
|
286
|
+
seen.add(memory.id);
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
if (distinct.length > 1)
|
|
290
|
+
conflicts.push({ subject, memories: distinct });
|
|
137
291
|
}
|
|
138
292
|
return conflicts.sort((a, b) => b.memories.length - a.memories.length);
|
|
139
293
|
}
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Rank memories against a query.
|
|
296
|
+
*
|
|
297
|
+
* Defaults to guidance — the population the pre-step may offer. The pull tool widens
|
|
298
|
+
* the target explicitly when the model asks what is *recorded* rather than what is
|
|
299
|
+
* *true*, and that difference is carried by the argument instead of by a second
|
|
300
|
+
* implementation.
|
|
301
|
+
*/
|
|
302
|
+
recall(query, limit = 5, now = Date.now(), target = 'guidance') {
|
|
303
|
+
return rankRecall(this.all(), query, limit, now, target);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The newest memories in one population, for a caller that asked without a query.
|
|
307
|
+
*
|
|
308
|
+
* "What do you remember" has no lexical content to rank, and answering it with an
|
|
309
|
+
* empty result would be pedantically correct and useless. Newest-first is the one
|
|
310
|
+
* ordering that needs no claim about relevance — it is simply the log's own order,
|
|
311
|
+
* read backwards.
|
|
312
|
+
*/
|
|
313
|
+
recent(limit = 5, target = 'guidance') {
|
|
314
|
+
return this.all().filter((memory) => inTarget(memory, target)).slice(0, limit);
|
|
315
|
+
}
|
|
316
|
+
// ─── Candidates ─
|
|
317
|
+
//
|
|
318
|
+
// A statement the heuristics noticed but the user never asked to keep. It lives in the
|
|
319
|
+
// queue until a person decides; nothing here reaches recall.
|
|
320
|
+
/**
|
|
321
|
+
* Offer a statement.
|
|
322
|
+
*
|
|
323
|
+
* Returns undefined when it is already settled — an active memory, an open candidate,
|
|
324
|
+
* or one already dismissed. That check is against the *log*, not against the message,
|
|
325
|
+
* so a sentence repeated across a session is asked once rather than on every step.
|
|
326
|
+
*/
|
|
327
|
+
propose(input) {
|
|
328
|
+
return this.queue.propose(input, this);
|
|
329
|
+
}
|
|
330
|
+
candidates() {
|
|
331
|
+
return this.queue.pending();
|
|
332
|
+
}
|
|
333
|
+
dismiss(id, now = Date.now()) {
|
|
334
|
+
return this.queue.decide(id, 'dismissed', now);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Turn a candidate into a memory.
|
|
338
|
+
*
|
|
339
|
+
* The decision is recorded *before* the memory is written, so a write that throws
|
|
340
|
+
* leaves a dismissed-looking proposal rather than a candidate that reappears beside a
|
|
341
|
+
* memory it duplicates. The candidate keeps the kind and provenance it was proposed
|
|
342
|
+
* with, and the user's acceptance is the reason it exists — which is why the memory is
|
|
343
|
+
* written with `source: 'user'`: a person said yes to it.
|
|
344
|
+
*/
|
|
345
|
+
accept(id, overrides = {}, now = Date.now()) {
|
|
346
|
+
const candidate = this.queue.get(id);
|
|
347
|
+
if (candidate === undefined || candidate.status !== 'pending')
|
|
348
|
+
return undefined;
|
|
349
|
+
const text = overrides.text?.trim() ?? candidate.text;
|
|
350
|
+
if (text.length === 0)
|
|
351
|
+
return undefined;
|
|
352
|
+
this.queue.decide(id, 'accepted', now);
|
|
353
|
+
return this.remember({
|
|
354
|
+
text,
|
|
355
|
+
kind: overrides.kind ?? candidate.kind,
|
|
356
|
+
scope: overrides.scope ?? 'project',
|
|
357
|
+
source: 'user',
|
|
358
|
+
...(candidate.sessionId === undefined ? {} : { sessionId: candidate.sessionId }),
|
|
359
|
+
}, now);
|
|
143
360
|
}
|
|
144
361
|
/**
|
|
145
362
|
* Re-run every compiled guard.
|
|
@@ -148,8 +365,16 @@ export class ContextMemory {
|
|
|
148
365
|
* disagreeing with a memory, `broken` is the check itself being unusable, and
|
|
149
366
|
* `uncheckable` is there being no check. Only the first is evidence about the memory,
|
|
150
367
|
* so only the first may influence a caller's exit code.
|
|
368
|
+
*
|
|
369
|
+
* @param options.record - when false, results are computed but not appended. The
|
|
370
|
+
* pre-step re-checks often; a log entry per check per step would grow the file
|
|
371
|
+
* without saying anything new.
|
|
372
|
+
* @param options.changedOnly - append only when the outcome differs from the last
|
|
373
|
+
* recorded one, so a memory that keeps passing does not add a line per run.
|
|
151
374
|
*/
|
|
152
|
-
verify(now = Date.now()) {
|
|
375
|
+
verify(now = Date.now(), options = {}) {
|
|
376
|
+
const record = options.record ?? true;
|
|
377
|
+
const changedOnly = options.changedOnly ?? false;
|
|
153
378
|
const summary = { passed: 0, violated: 0, broken: 0, uncheckable: 0, results: [] };
|
|
154
379
|
for (const memory of this.active()) {
|
|
155
380
|
if (!memory.guard) {
|
|
@@ -157,9 +382,27 @@ export class ContextMemory {
|
|
|
157
382
|
summary.results.push({ id: memory.id, text: memory.text, outcome: 'uncheckable', exitCode: null, output: '' });
|
|
158
383
|
continue;
|
|
159
384
|
}
|
|
385
|
+
// Guards describe files in the project, so they resolve against the project even
|
|
386
|
+
// when the statement itself is user-global.
|
|
160
387
|
const run = runGuard(memory.id, memory.guard, this.projectDir, new Date(now));
|
|
161
|
-
|
|
162
|
-
|
|
388
|
+
if (record && (!changedOnly || memory.verification?.outcome !== run.outcome)) {
|
|
389
|
+
this.storeFor(memory.scope).append({
|
|
390
|
+
type: 'verify',
|
|
391
|
+
at: now,
|
|
392
|
+
id: memory.id,
|
|
393
|
+
outcome: run.outcome,
|
|
394
|
+
exitCode: run.exitCode,
|
|
395
|
+
output: run.output,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const result = {
|
|
399
|
+
id: memory.id,
|
|
400
|
+
text: memory.text,
|
|
401
|
+
guard: memory.guard,
|
|
402
|
+
outcome: run.outcome,
|
|
403
|
+
exitCode: run.exitCode,
|
|
404
|
+
output: run.output,
|
|
405
|
+
};
|
|
163
406
|
summary.results.push(result);
|
|
164
407
|
if (run.outcome === 'passed')
|
|
165
408
|
summary.passed += 1;
|
|
@@ -184,16 +427,23 @@ export class ContextMemory {
|
|
|
184
427
|
const all = this.all();
|
|
185
428
|
const active = all.filter((memory) => memory.status === 'active');
|
|
186
429
|
const superseded = all.filter((memory) => memory.status === 'superseded');
|
|
430
|
+
const contradicted = active.filter(isContradicted);
|
|
431
|
+
const guidance = active.filter((memory) => !isContradicted(memory));
|
|
432
|
+
const candidates = this.candidates();
|
|
187
433
|
return {
|
|
188
|
-
active,
|
|
434
|
+
active: guidance,
|
|
189
435
|
superseded,
|
|
436
|
+
contradicted,
|
|
190
437
|
conflicts: this.conflicts(),
|
|
438
|
+
candidates,
|
|
191
439
|
totals: {
|
|
192
|
-
active:
|
|
440
|
+
active: guidance.length,
|
|
193
441
|
superseded: superseded.length,
|
|
442
|
+
contradicted: contradicted.length,
|
|
194
443
|
withGuard: active.filter((memory) => memory.guard).length,
|
|
195
444
|
verified: active.filter((memory) => memory.verification?.outcome === 'passed').length,
|
|
196
445
|
unverified: active.filter((memory) => memory.verification?.outcome !== 'passed').length,
|
|
446
|
+
candidates: candidates.length,
|
|
197
447
|
},
|
|
198
448
|
};
|
|
199
449
|
}
|
package/lib/memory.web.js
CHANGED
|
@@ -73,7 +73,13 @@ var zh = {
|
|
|
73
73
|
"memoryTextPlaceholder": "\u8981\u8BB0\u4F4F\u7684\u5185\u5BB9\uFF0C\u4E00\u53E5\u8BDD\u8BF4\u6E05",
|
|
74
74
|
"subjectPlaceholder": "\u7559\u7A7A\u5219\u81EA\u52A8\u53D6\u4E3B\u8BED",
|
|
75
75
|
"forgotten": "\u5DF2\u9057\u5FD8",
|
|
76
|
-
"confirmForget": "\u8FD9\u6761\u8BB0\u5FC6\u4F1A\u88AB\u5220\u9664\uFF0C\u4E14\u4E0D\u518D\u53C2\u4E0E\u53EC\u56DE\u3002"
|
|
76
|
+
"confirmForget": "\u8FD9\u6761\u8BB0\u5FC6\u4F1A\u88AB\u5220\u9664\uFF0C\u4E14\u4E0D\u518D\u53C2\u4E0E\u53EC\u56DE\u3002",
|
|
77
|
+
"candidates": "\u5F85\u786E\u8BA4",
|
|
78
|
+
"candidatesHint": "\u8FD9\u4E9B\u662F\u4F60\u8BF4\u8FC7\u3001\u4F46\u6CA1\u8BA9\u6211\u8BB0\u7684\u8BDD\u3002\u786E\u8BA4\u540E\u624D\u4F1A\u8FDB\u53EC\u56DE\uFF0C\u4E0D\u786E\u8BA4\u5C31\u4E00\u76F4\u7559\u5728\u8FD9\u91CC\u3002",
|
|
79
|
+
"accept": "\u8BB0\u4E0B",
|
|
80
|
+
"dismiss": "\u4E0D\u7528",
|
|
81
|
+
"candidateAccepted": "\u5DF2\u8BB0\u4E0B",
|
|
82
|
+
"candidateDismissed": "\u5DF2\u5FFD\u7565"
|
|
77
83
|
};
|
|
78
84
|
var en = {
|
|
79
85
|
"nav": "Context memory",
|
|
@@ -119,7 +125,13 @@ var en = {
|
|
|
119
125
|
"memoryTextPlaceholder": "What to remember, in one sentence",
|
|
120
126
|
"subjectPlaceholder": "Leave empty to infer the subject",
|
|
121
127
|
"forgotten": "Forgotten",
|
|
122
|
-
"confirmForget": "This memory is deleted and no longer recalled."
|
|
128
|
+
"confirmForget": "This memory is deleted and no longer recalled.",
|
|
129
|
+
"candidates": "Suggested",
|
|
130
|
+
"candidatesHint": "Things you said that were not asked to be remembered. They only enter recall once you accept them.",
|
|
131
|
+
"accept": "Remember it",
|
|
132
|
+
"dismiss": "No thanks",
|
|
133
|
+
"candidateAccepted": "Remembered",
|
|
134
|
+
"candidateDismissed": "Dismissed"
|
|
123
135
|
};
|
|
124
136
|
|
|
125
137
|
// src/client/view.tsx
|
|
@@ -661,6 +673,7 @@ var RECALL_PATH = "/api/context-memory.recall";
|
|
|
661
673
|
var REMEMBER_PATH = "/api/context-memory.remember";
|
|
662
674
|
var FORGET_PATH = "/api/context-memory.forget";
|
|
663
675
|
var PREFS_PATH = "/api/context-memory.prefs";
|
|
676
|
+
var CANDIDATES_PATH = "/api/context-memory.candidates";
|
|
664
677
|
function checkLabel(memory, t) {
|
|
665
678
|
if (!memory.guard) return t("checkNone");
|
|
666
679
|
switch (memory.verification?.outcome) {
|
|
@@ -675,7 +688,10 @@ function checkLabel(memory, t) {
|
|
|
675
688
|
}
|
|
676
689
|
}
|
|
677
690
|
function provenance(memory) {
|
|
678
|
-
return [memory.source, memory.sessionId !== void 0 ? `session ${memory.sessionId}` : null, memory.step !== void 0 ? `step ${String(memory.step)}` : null].filter((part) => part !== null).join(" \xB7 ");
|
|
691
|
+
return [memory.scope, memory.source, memory.sessionId !== void 0 ? `session ${memory.sessionId}` : null, memory.step !== void 0 ? `step ${String(memory.step)}` : null].filter((part) => part !== null).join(" \xB7 ");
|
|
692
|
+
}
|
|
693
|
+
function memoryKey(memory) {
|
|
694
|
+
return `${memory.scope}:${memory.id}`;
|
|
679
695
|
}
|
|
680
696
|
function AddMemoryModal({ t, onClose, onAdded }) {
|
|
681
697
|
const toast = useToast();
|
|
@@ -766,6 +782,13 @@ function ContextMemoryPanelInner({ t }) {
|
|
|
766
782
|
reload();
|
|
767
783
|
}
|
|
768
784
|
}, [forgetTarget, t, toast, reload]);
|
|
785
|
+
const decideCandidate = (0, import_react2.useCallback)(async (id, action) => {
|
|
786
|
+
const r = await fetch(CANDIDATES_PATH, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, action }) });
|
|
787
|
+
if (r.ok) {
|
|
788
|
+
toast("success", action === "accept" ? t("candidateAccepted") : t("candidateDismissed"));
|
|
789
|
+
reload();
|
|
790
|
+
}
|
|
791
|
+
}, [t, toast, reload]);
|
|
769
792
|
const header = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("header", { style: { display: "flex", alignItems: "center", gap: 12, marginBottom: 4 }, children: [
|
|
770
793
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("strong", { style: { fontSize: 15 }, children: [
|
|
771
794
|
"\u{1F9E0} ",
|
|
@@ -796,7 +819,8 @@ function ContextMemoryPanelInner({ t }) {
|
|
|
796
819
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.active, label: t("active") }),
|
|
797
820
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.verified, label: t("checkPassed") }),
|
|
798
821
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.unverified, label: t("unverified") }),
|
|
799
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.superseded, label: t("superseded") })
|
|
822
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.superseded, label: t("superseded") }),
|
|
823
|
+
payload.totals.candidates > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(StatCard, { value: payload.totals.candidates, label: t("candidates") })
|
|
800
824
|
] }),
|
|
801
825
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Card, { title: t("integration"), icon: "\u2699\uFE0F", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [["autoCapture", "autoCaptureLabel"], ["recallInject", "recallInjectLabel"]].map(([key, labelKey]) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer" }, children: [
|
|
802
826
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "checkbox", checked: prefs?.[key] ?? true, onChange: () => togglePref(key) }),
|
|
@@ -838,6 +862,23 @@ function ContextMemoryPanelInner({ t }) {
|
|
|
838
862
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Button, { variant: "danger", size: "sm", onClick: () => setForgetTarget({ id: memory.id, reason: `conflict resolved: ${conflict.subject}` }), children: t("resolve") })
|
|
839
863
|
] }, memory.id)) }, conflict.subject))
|
|
840
864
|
] }),
|
|
865
|
+
payload.candidates.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
866
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(SectionTitle, { icon: "\u{1F4A1}", children: [
|
|
867
|
+
t("candidates"),
|
|
868
|
+
" (",
|
|
869
|
+
payload.candidates.length,
|
|
870
|
+
")"
|
|
871
|
+
] }),
|
|
872
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Card, { children: [
|
|
873
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: { margin: "0 0 10px", fontSize: 12, opacity: 0.7 }, children: t("candidatesHint") }),
|
|
874
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: payload.candidates.map((candidate) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
|
|
875
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Badge, { children: candidate.kind }),
|
|
876
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { fontSize: 12, flex: 1 }, children: candidate.text }),
|
|
877
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Button, { variant: "primary", size: "sm", onClick: () => void decideCandidate(candidate.id, "accept"), children: t("accept") }),
|
|
878
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Button, { variant: "secondary", size: "sm", onClick: () => void decideCandidate(candidate.id, "dismiss"), children: t("dismiss") })
|
|
879
|
+
] }, candidate.id)) })
|
|
880
|
+
] })
|
|
881
|
+
] }),
|
|
841
882
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SectionTitle, { icon: "\u2705", children: t("active") }),
|
|
842
883
|
payload.active.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(EmptyState, { icon: "\u{1F4ED}", message: t("empty") }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Card, { padding: 0, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("table", { style: tableStyles.table, children: [
|
|
843
884
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("tr", { children: [
|
|
@@ -854,8 +895,8 @@ function ContextMemoryPanelInner({ t }) {
|
|
|
854
895
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Badge, { children: memory.kind }) }),
|
|
855
896
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: checkLabel(memory, t) }),
|
|
856
897
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: { ...tableStyles.td, fontSize: 11, opacity: 0.6 }, children: provenance(memory) }),
|
|
857
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Button, { variant: "danger", size: "sm", onClick: () => setForgetTarget({ id: memory.id, reason: "superseded from panel" }), children: "\u{1F5D1}" }) })
|
|
858
|
-
] }, memory
|
|
898
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Button, { variant: "danger", size: "sm", onClick: () => setForgetTarget({ id: memory.id, scope: memory.scope, reason: "superseded from panel" }), children: "\u{1F5D1}" }) })
|
|
899
|
+
] }, memoryKey(memory))) })
|
|
859
900
|
] }) }),
|
|
860
901
|
payload.superseded.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
861
902
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(SectionTitle, { icon: "\u{1F4E6}", children: [
|
|
@@ -876,7 +917,7 @@ function ContextMemoryPanelInner({ t }) {
|
|
|
876
917
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: memory.text }),
|
|
877
918
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Badge, { children: memory.kind }) }),
|
|
878
919
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("td", { style: tableStyles.td, children: checkLabel(memory, t) })
|
|
879
|
-
] }, memory
|
|
920
|
+
] }, memoryKey(memory))) })
|
|
880
921
|
] }) })
|
|
881
922
|
] }),
|
|
882
923
|
addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AddMemoryModal, { t, onClose: () => setAddOpen(false), onAdded: reload }),
|