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/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 { compileGuard, runGuard } from './guard.js';
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
- store;
18
- constructor(projectDir) {
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.store = new MemoryStore(projectDir);
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.store.path;
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 log into current state.
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 event of this.store.readAll()) {
36
- if (event.type === 'remember') {
37
- byId.set(event.memory.id, { ...event.memory, status: 'active' });
38
- continue;
39
- }
40
- const target = byId.get(event.id);
41
- if (!target)
42
- continue;
43
- if (event.type === 'supersede') {
44
- target.status = 'superseded';
45
- target.supersededReason = event.reason;
46
- if (event.replacedBy !== undefined)
47
- target.supersededBy = event.replacedBy;
48
- continue;
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
- return [...byId.values()].sort((a, b) => b.createdAt - a.createdAt);
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
- get(id) {
58
- return this.all().find((memory) => memory.id === id);
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
- const memory = { id, subject, text, kind: input.kind ?? 'fact', source: input.source ?? 'user', createdAt: now };
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
- this.store.append({ type: 'remember', at: now, memory });
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, replacedBy, now = Date.now()) {
106
- if (!this.get(id))
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.store.append(event);
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.active()) {
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
- if (memories.length > 1)
136
- conflicts.push({ subject, memories });
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
- /** Rank active memories against a query. */
141
- recall(query, limit = 5, now = Date.now()) {
142
- return rankRecall(this.all(), query, limit, now);
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
- this.store.append({ type: 'verify', at: now, id: memory.id, outcome: run.outcome, exitCode: run.exitCode, output: run.output });
162
- const result = { id: memory.id, text: memory.text, outcome: run.outcome, exitCode: run.exitCode, output: run.output };
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: active.length,
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.id)) })
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.id)) })
920
+ ] }, memoryKey(memory))) })
880
921
  ] }) })
881
922
  ] }),
882
923
  addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AddMemoryModal, { t, onClose: () => setAddOpen(false), onAdded: reload }),