memoir-cli 3.11.2 → 3.12.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.
@@ -40,6 +40,43 @@ const MAX_QUESTIONS = 5;
40
40
  const MAX_DECISIONS_RECENT = 10;
41
41
  const MAX_HISTORY = 30;
42
42
 
43
+ // ── Decision identity ────────────────────────────────────────────
44
+ //
45
+ // SPEC.md 5.1: a decision's identity is its normalized text. A PURGED
46
+ // tombstone (memoir forget --purge) has had that text redacted, so it
47
+ // carries `text_hash` = sha256(identity) instead and matches by hash.
48
+ // Both forms resolve to the same key here so unionByText/capDecisions
49
+ // treat "the original" and "the purged tombstone of the original" as one
50
+ // identity — that is what lets the tombstone keep suppressing copies of
51
+ // the un-purged text on replicas that never saw the purge.
52
+ export const PURGED_TEXT = '[purged]';
53
+
54
+ export function decisionIdentity(text) {
55
+ return String(text || '').trim().toLowerCase();
56
+ }
57
+
58
+ export function decisionHash(text) {
59
+ return crypto.createHash('sha256').update(decisionIdentity(text)).digest('hex');
60
+ }
61
+
62
+ function decisionKey(item) {
63
+ if (!item) return null;
64
+ if (item.text_hash) return `sha256:${item.text_hash}`;
65
+ if (!item.text) return null;
66
+ return `sha256:${decisionHash(item.text)}`;
67
+ }
68
+
69
+ // Cap decisions WITHOUT evicting tombstones. A plain `slice(0, cap)` after
70
+ // an unshift meant the 11th note pushed the oldest hidden decision off the
71
+ // list — and a dropped tombstone is a resurrection waiting for the next
72
+ // merge with any replica still holding the un-hidden copy. Tombstones and
73
+ // visible entries get separate budgets, same as unionByText below.
74
+ function capDecisions(list = [], cap = MAX_DECISIONS_RECENT) {
75
+ const visible = list.filter((d) => d && !d.hidden).slice(0, cap);
76
+ const tombstones = list.filter((d) => d && d.hidden).slice(0, cap);
77
+ return [...visible, ...tombstones];
78
+ }
79
+
43
80
  // ── Machine identity ─────────────────────────────────────────────
44
81
 
45
82
  // Stable per-machine identifier. Persisted once, reused forever.
@@ -263,7 +300,7 @@ export async function addNote(text, opts = {}) {
263
300
  if (opts.why) decision.why = opts.why;
264
301
  if (opts.rejected) decision.rejected = opts.rejected;
265
302
  state.current.decisions.unshift(decision);
266
- state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
303
+ state.current.decisions = capDecisions(state.current.decisions);
267
304
  await writeSession(state);
268
305
  // Count/booleans only — never the decision text itself.
269
306
  await appendEvent('decision_captured', { has_why: !!opts.why, has_rejected: !!opts.rejected });
@@ -271,6 +308,63 @@ export async function addNote(text, opts = {}) {
271
308
  });
272
309
  }
273
310
 
311
+ /**
312
+ * Find visible decisions matching a query — substring on text/why/rejected,
313
+ * or an exact identity match. Pure; shared by `memoir forget` and the
314
+ * memoir_forget MCP tool so both agree on what "matches" means.
315
+ */
316
+ export function matchDecisions(state, query) {
317
+ const q = decisionIdentity(query);
318
+ if (!q) return [];
319
+ const decisions = (state.current?.decisions || []).filter((d) => d && d.text && !d.hidden);
320
+ const exact = decisions.filter((d) => decisionIdentity(d.text) === q);
321
+ if (exact.length) return exact;
322
+ return decisions.filter((d) =>
323
+ [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase().includes(q)
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Forget a decision: set the SPEC.md 5.3.1 absolute tombstone
329
+ * (`hidden: true` + `hidden_at`) on the decision whose identity is `text`.
330
+ *
331
+ * With `purge`, the text/why/rejected are also redacted in place and the
332
+ * entry keeps only `text_hash` as its identity — for when the thing to
333
+ * forget is a leaked secret and hiding it from render is not enough. The
334
+ * hash still lets the tombstone suppress un-purged copies on other
335
+ * replicas at merge time (see unionByText).
336
+ *
337
+ * Deliberately NOT a delete: removal does not survive union-merge (the
338
+ * exact bug 3.10.2 fixed for next_actions). And there is no un-forget —
339
+ * `hidden` is monotonic by spec, which is why the CLI confirms first.
340
+ */
341
+ export async function hideDecision(text, { purge = false } = {}) {
342
+ return withSessionLock(SESSION_LOCK_PATH, async () => {
343
+ const state = await readSession();
344
+ await touchMachine(state);
345
+ const key = decisionIdentity(text);
346
+ const idx = (state.current.decisions || []).findIndex(
347
+ (d) => d && d.text && !d.hidden && decisionIdentity(d.text) === key
348
+ );
349
+ if (idx < 0) return { state, hidden: false };
350
+
351
+ const now = new Date().toISOString();
352
+ const d = state.current.decisions[idx];
353
+ const tomb = { ...d, hidden: true, hidden_at: now };
354
+ if (purge) {
355
+ tomb.text_hash = decisionHash(d.text);
356
+ tomb.text = PURGED_TEXT;
357
+ delete tomb.why;
358
+ delete tomb.rejected;
359
+ }
360
+ state.current.decisions[idx] = tomb;
361
+ state.current.decisions = capDecisions(state.current.decisions);
362
+ await writeSession(state);
363
+ await appendEvent('decision_hidden', { purged: !!purge });
364
+ return { state, hidden: true, purged: !!purge };
365
+ });
366
+ }
367
+
274
368
  export async function addQuestion(text) {
275
369
  return withSessionLock(SESSION_LOCK_PATH, async () => {
276
370
  const state = await readSession();
@@ -360,9 +454,14 @@ export function mergeSessions(local, remote) {
360
454
 
361
455
  function unionByText(a = [], b = [], dateField, cap) {
362
456
  const byText = new Map();
457
+ // Identity is normalized text (SPEC 5.1). Keyed through decisionKey so a
458
+ // PURGED decision tombstone — text redacted, `text_hash` kept — lands on
459
+ // the same key as the un-purged copies it must keep suppressing. For
460
+ // goals/next_actions/questions (no purge concept) this is just a hash of
461
+ // the same normalized text and behaves exactly as before.
363
462
  for (const item of [...a, ...b]) {
364
- if (!item || !item.text) continue;
365
- const key = item.text.trim().toLowerCase();
463
+ const key = decisionKey(item);
464
+ if (!key) continue;
366
465
  const existing = byText.get(key);
367
466
  if (!existing || new Date(item[dateField] || 0) > new Date(existing[dateField] || 0)) {
368
467
  byText.set(key, item);
@@ -379,19 +478,31 @@ function unionByText(a = [], b = [], dateField, cap) {
379
478
  // the tombstoned copy doesn't even win the date comparison.) Suppression has
380
479
  // to be monotonic or it isn't suppression — you'd be re-hiding the same junk
381
480
  // on every machine forever.
481
+ //
482
+ // A PURGED tombstone wins outright — never let a date-winning un-purged
483
+ // copy carry the redacted text back into the merged result. Purge is
484
+ // "this text must leave the file"; the merged entry must be the purged one.
382
485
  for (const [key, winner] of byText) {
383
- if (winner.hidden) continue;
384
- const tombstone = [...a, ...b].find(
385
- (i) => i && i.text && i.text.trim().toLowerCase() === key && i.hidden
386
- );
387
- if (tombstone) {
486
+ const stones = [...a, ...b].filter((i) => i && i.hidden && decisionKey(i) === key);
487
+ if (!stones.length) continue;
488
+ const tombstone = stones.find((i) => i.text_hash) || stones[0];
489
+ if (tombstone.text_hash) {
490
+ byText.set(key, tombstone);
491
+ } else if (!winner.hidden) {
388
492
  byText.set(key, { ...winner, hidden: true, hidden_at: tombstone.hidden_at });
389
493
  }
390
494
  }
391
495
 
392
- return Array.from(byText.values())
393
- .sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0))
394
- .slice(0, cap);
496
+ // Partition before capping. Tombstones keep their original (recent) date,
497
+ // so a plain sort+slice let them win cap slots and silently evict real
498
+ // entries on merge. They must SURVIVE the merge (removing them
499
+ // reintroduces the resurrection the sticky-tombstone rule fixed) but must
500
+ // not count against the visible budget.
501
+ const all = Array.from(byText.values())
502
+ .sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
503
+ const visible = all.filter((i) => !i.hidden).slice(0, cap);
504
+ const tombstones = all.filter((i) => i.hidden).slice(0, cap);
505
+ return [...visible, ...tombstones];
395
506
  }
396
507
 
397
508
  function unionTombstones(a = [], b = []) {