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.
- package/README.md +20 -6
- package/bin/memoir.js +22 -0
- package/package.json +1 -1
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/commands/activate.js +25 -2
- package/src/commands/cloud.js +1 -1
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +31 -12
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +12 -4
- package/src/commands/session.js +8 -3
- package/src/commands/upgrade.js +2 -2
- package/src/commands/validate.js +13 -0
- package/src/context/capture.js +14 -2
- package/src/mcp.js +54 -139
- package/src/memory/search.js +503 -0
- package/src/providers/index.js +21 -0
- package/src/security/scanner.js +12 -4
- package/src/session/lock.js +36 -2
- package/src/session/state.js +122 -11
package/src/session/state.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 = []) {
|