memoir-cli 3.11.3 → 3.14.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 +129 -124
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +72 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +43 -9
- package/src/commands/cloud.js +56 -5
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +164 -161
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +13 -11
- package/src/commands/validate.js +16 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +135 -33
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +151 -283
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +598 -0
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +305 -34
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
package/src/session/state.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { repositoryState } from '../memory/repository.js';
|
|
1
2
|
// Session state: the canonical source of truth for "what are we working on"
|
|
2
3
|
// across sessions and machines. Rendered into CLAUDE.md (and other tools) as a
|
|
3
4
|
// pinned block at the top, guaranteed to load.
|
|
@@ -9,6 +10,7 @@ import fs from 'fs-extra';
|
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import os from 'os';
|
|
11
12
|
import crypto from 'crypto';
|
|
13
|
+
import { projectIdentity, visibleMemory } from '../memory/scope.js';
|
|
12
14
|
import { withSessionLock } from './lock.js';
|
|
13
15
|
import { SCHEMA_VERSION, migrateSessionData, emptySession } from './migrations.js';
|
|
14
16
|
// NOTE: events/log.js imports getMachineId FROM this module — this is a
|
|
@@ -33,12 +35,56 @@ export { SCHEMA_VERSION, emptySession };
|
|
|
33
35
|
// Prevents unbounded growth of the live pinned block.
|
|
34
36
|
const MAX_GOALS = 3;
|
|
35
37
|
const MAX_NEXT = 8;
|
|
38
|
+
// Overflow from next_actions goes here instead of vanishing. `slice(-MAX_NEXT)`
|
|
39
|
+
// used to drop the oldest item with no warning, no event and no render hint:
|
|
40
|
+
// three of the author's live next-actions disappeared in one week (2026-09-03
|
|
41
|
+
// → 09-04) while the store sat at exactly 8. Parked items stay rendered and
|
|
42
|
+
// completable; only when THIS list overflows is anything dropped, and that
|
|
43
|
+
// emits an event.
|
|
44
|
+
const MAX_PARKED = Infinity;
|
|
36
45
|
// Completion tombstones kept so merges can't resurrect finished actions.
|
|
37
46
|
// Must outlive every stale copy that might still carry the item.
|
|
38
|
-
const MAX_COMPLETED_TOMBSTONES =
|
|
47
|
+
const MAX_COMPLETED_TOMBSTONES = Infinity;
|
|
39
48
|
const MAX_QUESTIONS = 5;
|
|
40
49
|
const MAX_DECISIONS_RECENT = 10;
|
|
41
|
-
const MAX_HISTORY =
|
|
50
|
+
const MAX_HISTORY = Infinity;
|
|
51
|
+
|
|
52
|
+
// ── Decision identity ────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// SPEC.md 5.1: a decision's identity is its normalized text. A PURGED
|
|
55
|
+
// tombstone (memoir forget --purge) has had that text redacted, so it
|
|
56
|
+
// carries `text_hash` = sha256(identity) instead and matches by hash.
|
|
57
|
+
// Both forms resolve to the same key here so unionByText/capDecisions
|
|
58
|
+
// treat "the original" and "the purged tombstone of the original" as one
|
|
59
|
+
// identity — that is what lets the tombstone keep suppressing copies of
|
|
60
|
+
// the un-purged text on replicas that never saw the purge.
|
|
61
|
+
export const PURGED_TEXT = '[purged]';
|
|
62
|
+
|
|
63
|
+
export function decisionIdentity(text) {
|
|
64
|
+
return String(text || '').trim().toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function decisionHash(text) {
|
|
68
|
+
return crypto.createHash('sha256').update(decisionIdentity(text)).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decisionKey(item) {
|
|
72
|
+
if (!item) return null;
|
|
73
|
+
if (item.text_hash) return 'sha256:' + item.text_hash + (item.project ? ':' + item.project : '');
|
|
74
|
+
if (!item.text) return null;
|
|
75
|
+
return 'sha256:' + decisionHash(item.text) + (item.project ? ':' + item.project : '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cap decisions WITHOUT evicting tombstones. A plain `slice(0, cap)` after
|
|
79
|
+
// an unshift meant the 11th note pushed the oldest hidden decision off the
|
|
80
|
+
// list — and a dropped tombstone is a resurrection waiting for the next
|
|
81
|
+
// merge with any replica still holding the un-hidden copy. Tombstones and
|
|
82
|
+
// visible entries get separate budgets, same as unionByText below.
|
|
83
|
+
function capDecisions(list = [], cap = MAX_DECISIONS_RECENT) {
|
|
84
|
+
const visible = list.filter((d) => d && !d.hidden).slice(0, cap);
|
|
85
|
+
const tombstones = list.filter((d) => d && d.hidden);
|
|
86
|
+
return [...visible, ...tombstones];
|
|
87
|
+
}
|
|
42
88
|
|
|
43
89
|
// ── Machine identity ─────────────────────────────────────────────
|
|
44
90
|
|
|
@@ -153,6 +199,7 @@ export async function readSession() {
|
|
|
153
199
|
// critical section (see the mutators below and lock.js).
|
|
154
200
|
export async function writeSession(state) {
|
|
155
201
|
await fs.ensureDir(CONFIG_DIR);
|
|
202
|
+
partitionWorkingState(state);
|
|
156
203
|
state.version = SCHEMA_VERSION;
|
|
157
204
|
state.updated_at = new Date().toISOString();
|
|
158
205
|
const tmp = `${SESSION_PATH}.tmp-${process.pid}`;
|
|
@@ -183,14 +230,23 @@ export async function addGoal(text) {
|
|
|
183
230
|
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
184
231
|
const state = await readSession();
|
|
185
232
|
const machineId = await touchMachine(state);
|
|
233
|
+
const key = decisionIdentity(text);
|
|
234
|
+
// Re-setting an existing goal moves it to the front; it is not a duplicate.
|
|
235
|
+
state.current.goals = (state.current.goals || []).filter((g) => decisionIdentity(g?.text) !== key || !visibleMemory(g));
|
|
186
236
|
state.current.goals.unshift({
|
|
187
237
|
text,
|
|
238
|
+
id: crypto.randomUUID(),
|
|
239
|
+
project: projectIdentity(),
|
|
188
240
|
machine_id: machineId,
|
|
189
241
|
set_on: new Date().toISOString(),
|
|
190
242
|
});
|
|
191
|
-
|
|
243
|
+
// The cap still applies (a pinned block with ten goals is no focus at
|
|
244
|
+
// all) but a replaced goal is reported, never silently dropped.
|
|
245
|
+
const replaced = state.current.goals.slice(MAX_GOALS);
|
|
246
|
+
|
|
192
247
|
await writeSession(state);
|
|
193
|
-
await appendEvent('goal_set', {}); // no PII/content — count-and-type only
|
|
248
|
+
await appendEvent('goal_set', { replaced: replaced.length }); // no PII/content — count-and-type only
|
|
249
|
+
Object.defineProperty(state, 'replacedGoals', { value: replaced, enumerable: false });
|
|
194
250
|
return state;
|
|
195
251
|
});
|
|
196
252
|
}
|
|
@@ -201,16 +257,73 @@ export async function addNext(text) {
|
|
|
201
257
|
const machineId = await touchMachine(state);
|
|
202
258
|
// Dedupe by text (case-insensitive)
|
|
203
259
|
const normalized = text.trim().toLowerCase();
|
|
204
|
-
const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized);
|
|
260
|
+
const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized && visibleMemory(a));
|
|
261
|
+
let parked = [];
|
|
205
262
|
if (!exists) {
|
|
263
|
+
// Re-adding a parked item is "bring it back", not a duplicate.
|
|
264
|
+
state.current.parked_actions = (state.current.parked_actions || [])
|
|
265
|
+
.filter((a) => a?.text?.trim().toLowerCase() !== normalized || !visibleMemory(a));
|
|
206
266
|
state.current.next_actions.push({
|
|
207
267
|
text,
|
|
208
|
-
|
|
268
|
+
id: crypto.randomUUID(),
|
|
269
|
+
project: projectIdentity(),
|
|
270
|
+
machine_id: machineId,
|
|
209
271
|
added: new Date().toISOString(),
|
|
210
272
|
});
|
|
211
|
-
state.current.next_actions = state.current.next_actions
|
|
273
|
+
({ live: state.current.next_actions, parked } = parkOverflow(state.current.next_actions));
|
|
274
|
+
if (parked.length) {
|
|
275
|
+
const merged = [...parked, ...(state.current.parked_actions || [])];
|
|
276
|
+
const dropped = merged.slice(MAX_PARKED);
|
|
277
|
+
state.current.parked_actions = merged.slice(0, MAX_PARKED);
|
|
278
|
+
await appendEvent('next_parked', { count: parked.length, dropped: dropped.length });
|
|
279
|
+
}
|
|
212
280
|
}
|
|
213
281
|
await writeSession(state);
|
|
282
|
+
// Non-enumerable: callers can tell the user what was parked, nothing
|
|
283
|
+
// serialises it.
|
|
284
|
+
Object.defineProperty(state, 'justParked', { value: parked, enumerable: false });
|
|
285
|
+
return state;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Split a next_actions list into the MAX_NEXT newest (live) and the overflow
|
|
290
|
+
// (oldest first), stamping parked_at on the overflow. Pure; shared by
|
|
291
|
+
// addNext and mergeSessions so both agree on what "full" means.
|
|
292
|
+
function parkOverflow(list, cap = MAX_NEXT, now = new Date().toISOString()) {
|
|
293
|
+
if (list.length <= cap) return { live: list, parked: [] };
|
|
294
|
+
const overflow = list.slice(0, list.length - cap);
|
|
295
|
+
return {
|
|
296
|
+
live: list.slice(list.length - cap),
|
|
297
|
+
parked: overflow.map((a) => ({ ...a, parked_at: a.parked_at || now })),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Retire a goal. Same shape as completeNext: remove it AND record a
|
|
303
|
+
* temporal tombstone, because a plain removal comes straight back on the
|
|
304
|
+
* next union-merge with any copy that still carries it (the push-side
|
|
305
|
+
* backup, another machine). A goal re-set after its done_at survives.
|
|
306
|
+
*/
|
|
307
|
+
export async function completeGoal(match) {
|
|
308
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
309
|
+
const state = await readSession();
|
|
310
|
+
await touchMachine(state);
|
|
311
|
+
const normalized = String(match).trim().toLowerCase();
|
|
312
|
+
state.current.goals = [...(state.current.goals || []), ...(state.current.archived_goals || [])];
|
|
313
|
+
state.current.archived_goals = [];
|
|
314
|
+
const idx = state.current.goals.findIndex(g => visibleMemory(g) && g?.text?.trim().toLowerCase().includes(normalized));
|
|
315
|
+
const completed = idx >= 0;
|
|
316
|
+
if (completed) {
|
|
317
|
+
const [removed] = state.current.goals.splice(idx, 1);
|
|
318
|
+
const key = removed.text.trim().toLowerCase();
|
|
319
|
+
state.current.completed_goals = [
|
|
320
|
+
{ text: removed.text, project: removed.project, done_at: new Date().toISOString() },
|
|
321
|
+
...(state.current.completed_goals || []).filter((c) => c && c.text && decisionKey(c) !== decisionKey(removed)),
|
|
322
|
+
].slice(0, MAX_COMPLETED_TOMBSTONES);
|
|
323
|
+
}
|
|
324
|
+
await writeSession(state);
|
|
325
|
+
if (completed) await appendEvent('goal_completed', {});
|
|
326
|
+
Object.defineProperty(state, 'completed', { value: completed, enumerable: false });
|
|
214
327
|
return state;
|
|
215
328
|
});
|
|
216
329
|
}
|
|
@@ -220,11 +333,17 @@ export async function completeNext(textOrIndex) {
|
|
|
220
333
|
const state = await readSession();
|
|
221
334
|
await touchMachine(state);
|
|
222
335
|
let idx = -1;
|
|
336
|
+
let list = state.current.next_actions;
|
|
223
337
|
if (typeof textOrIndex === 'number') {
|
|
224
|
-
idx = textOrIndex;
|
|
338
|
+
idx = list.map((item, index) => ({ item, index })).filter(x => visibleMemory(x.item))[textOrIndex]?.index ?? -1;
|
|
225
339
|
} else {
|
|
226
340
|
const normalized = String(textOrIndex).trim().toLowerCase();
|
|
227
|
-
idx =
|
|
341
|
+
idx = list.findIndex(a => visibleMemory(a) && a.text.trim().toLowerCase().includes(normalized));
|
|
342
|
+
if (idx < 0) {
|
|
343
|
+
// Parked items are still real next-actions — finishing one must work.
|
|
344
|
+
list = state.current.parked_actions || [];
|
|
345
|
+
idx = list.findIndex(a => visibleMemory(a) && a?.text?.trim().toLowerCase().includes(normalized));
|
|
346
|
+
}
|
|
228
347
|
}
|
|
229
348
|
const completed = idx >= 0;
|
|
230
349
|
if (completed) {
|
|
@@ -234,12 +353,12 @@ export async function completeNext(textOrIndex) {
|
|
|
234
353
|
// bug. So completion also records a tombstone that merges consult.
|
|
235
354
|
// Temporal, not absolute like decisions' `hidden`: a re-add whose
|
|
236
355
|
// `added` postdates `done_at` is a deliberate revival and survives.
|
|
237
|
-
const [removed] =
|
|
356
|
+
const [removed] = list.splice(idx, 1);
|
|
238
357
|
const key = removed.text.trim().toLowerCase();
|
|
239
358
|
state.current.completed_actions = [
|
|
240
|
-
{ text: removed.text, done_at: new Date().toISOString() },
|
|
359
|
+
{ text: removed.text, project: removed.project, done_at: new Date().toISOString() },
|
|
241
360
|
...(state.current.completed_actions || []).filter(
|
|
242
|
-
c => c && c.text && c
|
|
361
|
+
c => c && c.text && decisionKey(c) !== decisionKey(removed)
|
|
243
362
|
),
|
|
244
363
|
].slice(0, MAX_COMPLETED_TOMBSTONES);
|
|
245
364
|
}
|
|
@@ -256,6 +375,8 @@ export async function addNote(text, opts = {}) {
|
|
|
256
375
|
const state = await readSession();
|
|
257
376
|
const machineId = await touchMachine(state);
|
|
258
377
|
const decision = {
|
|
378
|
+
id: crypto.randomUUID(),
|
|
379
|
+
project: projectIdentity(opts.project),
|
|
259
380
|
text,
|
|
260
381
|
machine_id: machineId,
|
|
261
382
|
date: new Date().toISOString(),
|
|
@@ -263,7 +384,6 @@ export async function addNote(text, opts = {}) {
|
|
|
263
384
|
if (opts.why) decision.why = opts.why;
|
|
264
385
|
if (opts.rejected) decision.rejected = opts.rejected;
|
|
265
386
|
state.current.decisions.unshift(decision);
|
|
266
|
-
state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
|
|
267
387
|
await writeSession(state);
|
|
268
388
|
// Count/booleans only — never the decision text itself.
|
|
269
389
|
await appendEvent('decision_captured', { has_why: !!opts.why, has_rejected: !!opts.rejected });
|
|
@@ -271,16 +391,76 @@ export async function addNote(text, opts = {}) {
|
|
|
271
391
|
});
|
|
272
392
|
}
|
|
273
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Find visible decisions matching a query — substring on text/why/rejected,
|
|
396
|
+
* or an exact identity match. Pure; shared by `memoir forget` and the
|
|
397
|
+
* memoir_forget MCP tool so both agree on what "matches" means.
|
|
398
|
+
*/
|
|
399
|
+
export function matchDecisions(state, query) {
|
|
400
|
+
const q = decisionIdentity(query);
|
|
401
|
+
if (!q) return [];
|
|
402
|
+
const decisions = allDecisions(state).filter((d) => d && d.text && visibleMemory(d));
|
|
403
|
+
const exact = decisions.filter((d) => decisionIdentity(d.text) === q);
|
|
404
|
+
if (exact.length) return exact;
|
|
405
|
+
return decisions.filter((d) =>
|
|
406
|
+
[d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase().includes(q)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Forget a decision: set the SPEC.md 5.3.1 absolute tombstone
|
|
412
|
+
* (`hidden: true` + `hidden_at`) on the decision whose identity is `text`.
|
|
413
|
+
*
|
|
414
|
+
* With `purge`, the text/why/rejected are also redacted in place and the
|
|
415
|
+
* entry keeps only `text_hash` as its identity — for when the thing to
|
|
416
|
+
* forget is a leaked secret and hiding it from render is not enough. The
|
|
417
|
+
* hash still lets the tombstone suppress un-purged copies on other
|
|
418
|
+
* replicas at merge time (see unionByText).
|
|
419
|
+
*
|
|
420
|
+
* Deliberately NOT a delete: removal does not survive union-merge (the
|
|
421
|
+
* exact bug 3.10.2 fixed for next_actions). And there is no un-forget —
|
|
422
|
+
* `hidden` is monotonic by spec, which is why the CLI confirms first.
|
|
423
|
+
*/
|
|
424
|
+
export async function hideDecision(text, { purge = false } = {}) {
|
|
425
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
426
|
+
const state = await readSession();
|
|
427
|
+
await touchMachine(state);
|
|
428
|
+
const key = decisionIdentity(text);
|
|
429
|
+
state.current.decisions = allDecisions(state);
|
|
430
|
+
state.current.archived_decisions = [];
|
|
431
|
+
const idx = (state.current.decisions || []).findIndex(
|
|
432
|
+
(d) => d && d.text && visibleMemory(d) && decisionIdentity(d.text) === key
|
|
433
|
+
);
|
|
434
|
+
if (idx < 0) return { state, hidden: false };
|
|
435
|
+
|
|
436
|
+
const now = new Date().toISOString();
|
|
437
|
+
const d = state.current.decisions[idx];
|
|
438
|
+
const tomb = { ...d, hidden: true, hidden_at: now };
|
|
439
|
+
if (purge) {
|
|
440
|
+
tomb.text_hash = decisionHash(d.text);
|
|
441
|
+
tomb.text = PURGED_TEXT;
|
|
442
|
+
delete tomb.why;
|
|
443
|
+
delete tomb.rejected;
|
|
444
|
+
}
|
|
445
|
+
state.current.decisions[idx] = tomb;
|
|
446
|
+
await writeSession(state);
|
|
447
|
+
await appendEvent('decision_hidden', { purged: !!purge });
|
|
448
|
+
return { state, hidden: true, purged: !!purge };
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
274
452
|
export async function addQuestion(text) {
|
|
275
453
|
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
276
454
|
const state = await readSession();
|
|
277
455
|
const machineId = await touchMachine(state);
|
|
278
456
|
state.current.open_questions.push({
|
|
279
457
|
text,
|
|
458
|
+
id: crypto.randomUUID(),
|
|
459
|
+
project: projectIdentity(),
|
|
280
460
|
machine_id: machineId,
|
|
281
461
|
asked: new Date().toISOString(),
|
|
282
462
|
});
|
|
283
|
-
|
|
463
|
+
|
|
284
464
|
await writeSession(state);
|
|
285
465
|
return state;
|
|
286
466
|
});
|
|
@@ -288,17 +468,32 @@ export async function addQuestion(text) {
|
|
|
288
468
|
|
|
289
469
|
// Roll up the current state into a history entry. Use at session end / push.
|
|
290
470
|
// Does not clear `current` — these are "the working set," not per-session scratch.
|
|
291
|
-
export async function recordSessionEnd({ summary, filesTouched = [], durationMin = null } = {}) {
|
|
471
|
+
export async function recordSessionEnd({ summary, filesTouched = [], durationMin = null, sessionId = null, project } = {}) {
|
|
292
472
|
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
293
473
|
const state = await readSession();
|
|
294
474
|
const machineId = await touchMachine(state);
|
|
295
|
-
|
|
475
|
+
const entry = {
|
|
296
476
|
date: new Date().toISOString(),
|
|
297
477
|
machine_id: machineId,
|
|
478
|
+
project: projectIdentity(project),
|
|
298
479
|
summary: summary || '',
|
|
299
480
|
files_touched: filesTouched.slice(0, 20),
|
|
300
481
|
duration_min: durationMin,
|
|
301
|
-
}
|
|
482
|
+
};
|
|
483
|
+
const repo = repositoryState(project || process.env.MEMOIR_PROJECT_ROOT || process.cwd());
|
|
484
|
+
entry.repo_head = repo.head;
|
|
485
|
+
entry.branch = repo.branch;
|
|
486
|
+
entry.working_tree_dirty = repo.dirty;
|
|
487
|
+
if (sessionId) entry.session_id = sessionId;
|
|
488
|
+
// Autopush fires after every response, so one long session used to fill
|
|
489
|
+
// all five "Recent sessions" rows with itself. Same session → update its
|
|
490
|
+
// row in place (duration and files grow), don't add another.
|
|
491
|
+
const existing = sessionId ? state.history.findIndex((h) => h?.session_id === sessionId) : -1;
|
|
492
|
+
if (existing >= 0) {
|
|
493
|
+
entry.date = state.history[existing].date || entry.date;
|
|
494
|
+
state.history.splice(existing, 1);
|
|
495
|
+
}
|
|
496
|
+
state.history.unshift(entry);
|
|
302
497
|
state.history = state.history.slice(0, MAX_HISTORY);
|
|
303
498
|
await writeSession(state);
|
|
304
499
|
return state;
|
|
@@ -320,14 +515,47 @@ export function mergeSessions(local, remote) {
|
|
|
320
515
|
updated_at: latest(local.updated_at, remote.updated_at),
|
|
321
516
|
machines: { ...remote.machines, ...local.machines }, // local wins for same machine
|
|
322
517
|
current: {
|
|
323
|
-
goals: unionByText(local.current?.goals, remote.current?.goals, 'set_on',
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
518
|
+
goals: unionByText([...(local.current?.goals || []), ...(local.current?.archived_goals || [])], [...(remote.current?.goals || []), ...(remote.current?.archived_goals || [])], 'set_on', Infinity),
|
|
519
|
+
archived_goals: [],
|
|
520
|
+
// Live + parked from both sides pooled, then re-split below: the
|
|
521
|
+
// MAX_NEXT newest are live, the rest parked. A capped union here used
|
|
522
|
+
// to evict the oldest on merge just as silently as addNext did.
|
|
523
|
+
next_actions: unionByText(
|
|
524
|
+
[...(local.current?.next_actions || []), ...(local.current?.parked_actions || [])],
|
|
525
|
+
[...(remote.current?.next_actions || []), ...(remote.current?.parked_actions || [])],
|
|
526
|
+
'added', Infinity),
|
|
527
|
+
parked_actions: [],
|
|
528
|
+
open_questions: unionByText([...(local.current?.open_questions || []), ...(local.current?.archived_questions || [])], [...(remote.current?.open_questions || []), ...(remote.current?.archived_questions || [])], 'asked', Infinity),
|
|
529
|
+
archived_questions: [],
|
|
530
|
+
decisions: unionByText(allDecisions(local), allDecisions(remote), 'date', Infinity),
|
|
531
|
+
archived_decisions: [],
|
|
327
532
|
},
|
|
328
533
|
history: mergeHistory(local.history, remote.history),
|
|
329
534
|
};
|
|
330
535
|
|
|
536
|
+
// Fields this build does not know about pass through from the local copy
|
|
537
|
+
// instead of being dropped. `current` is rebuilt from known keys above, so
|
|
538
|
+
// a merge performed by an OLDER memoir silently erased anything newer —
|
|
539
|
+
// live proof: minutes after 3.13.0 added parked_actions and
|
|
540
|
+
// completed_goals, a Stop-hook push still running 3.12 rebuilt `current`
|
|
541
|
+
// without them and wrote that back locally, undoing two goal retirements
|
|
542
|
+
// and a parked item. Local wins over remote for unknown keys because an
|
|
543
|
+
// old build cannot merge what it cannot read.
|
|
544
|
+
const KNOWN_CURRENT = new Set(['goals', 'next_actions', 'parked_actions', 'open_questions', 'decisions', 'archived_decisions', 'archived_goals', 'archived_questions', 'completed_actions', 'completed_goals']);
|
|
545
|
+
for (const src of [remote.current || {}, local.current || {}]) {
|
|
546
|
+
for (const [k, v] of Object.entries(src)) {
|
|
547
|
+
if (!KNOWN_CURRENT.has(k)) merged.current[k] = v;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Goal tombstones — same temporal rule as next_actions below.
|
|
552
|
+
const goalTombstones = unionTombstones(local.current?.completed_goals, remote.current?.completed_goals);
|
|
553
|
+
merged.current.completed_goals = goalTombstones;
|
|
554
|
+
merged.current.goals = merged.current.goals.filter((g) => {
|
|
555
|
+
const t = goalTombstones.find((c) => decisionKey(c) === decisionKey(g));
|
|
556
|
+
return !t || new Date(g.set_on || 0) > new Date(t.done_at);
|
|
557
|
+
});
|
|
558
|
+
|
|
331
559
|
// Completed-action tombstones beat the union above. unionByText can only
|
|
332
560
|
// union; it cannot represent "this used to exist and was finished," so a
|
|
333
561
|
// completed item surviving in ANY stale copy resurrected on every merge —
|
|
@@ -342,10 +570,16 @@ export function mergeSessions(local, remote) {
|
|
|
342
570
|
merged.current.completed_actions = tombstones;
|
|
343
571
|
merged.current.next_actions = merged.current.next_actions.filter(a => {
|
|
344
572
|
const t = tombstones.find(
|
|
345
|
-
c => c
|
|
573
|
+
c => decisionKey(c) === decisionKey(a)
|
|
346
574
|
);
|
|
347
575
|
return !t || new Date(a.added || 0) > new Date(t.done_at);
|
|
348
576
|
});
|
|
577
|
+
// unionByText returns newest-first; next_actions is stored oldest-first
|
|
578
|
+
// (render reverses). Re-split into live (newest MAX_NEXT) and parked.
|
|
579
|
+
const pooled = [...merged.current.next_actions].reverse();
|
|
580
|
+
const split = parkOverflow(pooled);
|
|
581
|
+
merged.current.next_actions = split.live.map((a) => { const { parked_at, ...rest } = a; return rest; });
|
|
582
|
+
merged.current.parked_actions = split.parked.reverse().slice(0, MAX_PARKED);
|
|
349
583
|
|
|
350
584
|
// machines: union last_seen per id (take the newer)
|
|
351
585
|
for (const [id, entry] of Object.entries(remote.machines || {})) {
|
|
@@ -355,20 +589,33 @@ export function mergeSessions(local, remote) {
|
|
|
355
589
|
}
|
|
356
590
|
}
|
|
357
591
|
|
|
592
|
+
partitionWorkingState(merged);
|
|
358
593
|
return merged;
|
|
359
594
|
}
|
|
360
595
|
|
|
361
596
|
function unionByText(a = [], b = [], dateField, cap) {
|
|
362
597
|
const byText = new Map();
|
|
598
|
+
// Identity is normalized text (SPEC 5.1). Keyed through decisionKey so a
|
|
599
|
+
// PURGED decision tombstone — text redacted, `text_hash` kept — lands on
|
|
600
|
+
// the same key as the un-purged copies it must keep suppressing. For
|
|
601
|
+
// goals/next_actions/questions (no purge concept) this is just a hash of
|
|
602
|
+
// the same normalized text and behaves exactly as before.
|
|
363
603
|
for (const item of [...a, ...b]) {
|
|
364
|
-
|
|
365
|
-
|
|
604
|
+
const key = decisionKey(item);
|
|
605
|
+
if (!key) continue;
|
|
366
606
|
const existing = byText.get(key);
|
|
367
607
|
if (!existing || new Date(item[dateField] || 0) > new Date(existing[dateField] || 0)) {
|
|
368
608
|
byText.set(key, item);
|
|
369
609
|
}
|
|
370
610
|
}
|
|
371
611
|
|
|
612
|
+
const stones = [...a, ...b].filter(i => i?.hidden);
|
|
613
|
+
for (const [key, item] of byText) {
|
|
614
|
+
if (item.project || item.hidden) continue;
|
|
615
|
+
const hash = item.text_hash || decisionHash(item.text);
|
|
616
|
+
if (stones.some(t => t.project && (t.text_hash || decisionHash(t.text)) === hash)) byText.delete(key);
|
|
617
|
+
}
|
|
618
|
+
|
|
372
619
|
// A tombstone is STICKY: once any machine marks an entry hidden, the merged
|
|
373
620
|
// result stays hidden, whatever the dates say.
|
|
374
621
|
//
|
|
@@ -379,12 +626,17 @@ function unionByText(a = [], b = [], dateField, cap) {
|
|
|
379
626
|
// the tombstoned copy doesn't even win the date comparison.) Suppression has
|
|
380
627
|
// to be monotonic or it isn't suppression — you'd be re-hiding the same junk
|
|
381
628
|
// on every machine forever.
|
|
629
|
+
//
|
|
630
|
+
// A PURGED tombstone wins outright — never let a date-winning un-purged
|
|
631
|
+
// copy carry the redacted text back into the merged result. Purge is
|
|
632
|
+
// "this text must leave the file"; the merged entry must be the purged one.
|
|
382
633
|
for (const [key, winner] of byText) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
|
|
634
|
+
const stones = [...a, ...b].filter((i) => i && i.hidden && decisionKey(i) === key);
|
|
635
|
+
if (!stones.length) continue;
|
|
636
|
+
const tombstone = stones.find((i) => i.text_hash) || stones[0];
|
|
637
|
+
if (tombstone.text_hash) {
|
|
638
|
+
byText.set(key, tombstone);
|
|
639
|
+
} else if (!winner.hidden) {
|
|
388
640
|
byText.set(key, { ...winner, hidden: true, hidden_at: tombstone.hidden_at });
|
|
389
641
|
}
|
|
390
642
|
}
|
|
@@ -396,8 +648,8 @@ function unionByText(a = [], b = [], dateField, cap) {
|
|
|
396
648
|
// not count against the visible budget.
|
|
397
649
|
const all = Array.from(byText.values())
|
|
398
650
|
.sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
|
|
399
|
-
const visible = all.filter((i) => !i.hidden).slice(0, cap);
|
|
400
|
-
const tombstones = all.filter((i) => i.hidden)
|
|
651
|
+
const visible = all.filter((i) => !i.hidden).slice(0, cap === Infinity ? undefined : cap);
|
|
652
|
+
const tombstones = all.filter((i) => i.hidden);
|
|
401
653
|
return [...visible, ...tombstones];
|
|
402
654
|
}
|
|
403
655
|
|
|
@@ -405,7 +657,7 @@ function unionTombstones(a = [], b = []) {
|
|
|
405
657
|
const byText = new Map();
|
|
406
658
|
for (const item of [...(a || []), ...(b || [])]) {
|
|
407
659
|
if (!item || !item.text || !item.done_at) continue;
|
|
408
|
-
const key = item
|
|
660
|
+
const key = decisionKey(item);
|
|
409
661
|
const existing = byText.get(key);
|
|
410
662
|
if (!existing || new Date(item.done_at) > new Date(existing.done_at)) {
|
|
411
663
|
byText.set(key, item);
|
|
@@ -420,8 +672,10 @@ function mergeHistory(a = [], b = []) {
|
|
|
420
672
|
const seen = new Set();
|
|
421
673
|
const all = [...a, ...b].filter(h => h && h.date);
|
|
422
674
|
// Dedupe by (date + machine_id + summary) — the three keys that make a session unique
|
|
423
|
-
|
|
424
|
-
|
|
675
|
+
// Newest copy of a session_id wins (in-place updates change summary/duration).
|
|
676
|
+
const sorted = all.sort((x, y) => new Date(y.date) - new Date(x.date));
|
|
677
|
+
const unique = sorted.filter(h => {
|
|
678
|
+
const key = h.session_id ? `sid:${h.session_id}` : `${h.date}|${h.machine_id}|${(h.summary || '').slice(0, 50)}`;
|
|
425
679
|
if (seen.has(key)) return false;
|
|
426
680
|
seen.add(key);
|
|
427
681
|
return true;
|
|
@@ -451,3 +705,20 @@ export const paths = {
|
|
|
451
705
|
machineId: MACHINE_ID_PATH,
|
|
452
706
|
sessionLock: SESSION_LOCK_PATH,
|
|
453
707
|
};
|
|
708
|
+
|
|
709
|
+
export function allDecisions(state) {
|
|
710
|
+
return [...(state?.current?.decisions || []), ...(state?.current?.archived_decisions || [])];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function partitionWorkingState(state) {
|
|
714
|
+
state.current ||= {};
|
|
715
|
+
for (const [live, archive, date, cap] of [
|
|
716
|
+
['decisions', 'archived_decisions', 'date', MAX_DECISIONS_RECENT],
|
|
717
|
+
['goals', 'archived_goals', 'set_on', MAX_GOALS],
|
|
718
|
+
['open_questions', 'archived_questions', 'asked', MAX_QUESTIONS],
|
|
719
|
+
]) {
|
|
720
|
+
const all = unionByText(state.current[live], state.current[archive], date, Infinity);
|
|
721
|
+
state.current[live] = capDecisions(all, cap);
|
|
722
|
+
state.current[archive] = all.filter(item => !item.hidden).slice(cap);
|
|
723
|
+
}
|
|
724
|
+
}
|
package/src/work/cli.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { recordWork, runWorkCheck, retractWork, refreshWork, formatWork } from './store.js';
|
|
3
|
+
import { setupWork } from './setup.js';
|
|
4
|
+
import { readSafeFile } from '../security/files.js';
|
|
5
|
+
|
|
6
|
+
export async function workCli(argv) {
|
|
7
|
+
const program = new Command('memoir work').description('Local project continuity for Codex and Cursor')
|
|
8
|
+
.option('--project <path>', 'Project directory', process.env.MEMOIR_PROJECT_ROOT || process.cwd());
|
|
9
|
+
const project = () => program.opts().project;
|
|
10
|
+
program.command('setup').option('--tools <names>', 'codex,cursor', 'codex,cursor').action(async options => {
|
|
11
|
+
console.log(JSON.stringify(await setupWork(project(), { tools: options.tools.split(',') }), null, 2));
|
|
12
|
+
});
|
|
13
|
+
program.command('resume').option('--json', 'Structured context').action(async options => {
|
|
14
|
+
const view = await refreshWork(project());
|
|
15
|
+
console.log(options.json ? JSON.stringify(view, null, 2) : formatWork(view));
|
|
16
|
+
});
|
|
17
|
+
program.command('view').description('Review and correct project memory in a local browser')
|
|
18
|
+
.option('--no-open', 'Print the local link without opening a browser').option('--port <number>', 'Local port; 0 chooses an available port', '0').action(async options => {
|
|
19
|
+
const { startWorkView } = await import('./view.js');
|
|
20
|
+
const view = await startWorkView(project(), { port: Number(options.port) });
|
|
21
|
+
console.log(`Memoir project view: ${view.url}\nOnly this computer. Keep this terminal open; press Ctrl+C to stop.`);
|
|
22
|
+
if (options.open) {
|
|
23
|
+
const { spawn } = await import('node:child_process');
|
|
24
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
25
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', view.url] : [view.url];
|
|
26
|
+
const child = spawn(command, args, { stdio:'ignore', shell:false });
|
|
27
|
+
child.on('error', () => console.error('Open the local link above in your browser.'));
|
|
28
|
+
}
|
|
29
|
+
const stop = async () => { await view.close(); process.exitCode = 0; };
|
|
30
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
31
|
+
});
|
|
32
|
+
program.command('record').option('--json <record>', 'Project record JSON').option('--file <path>', 'Project-relative JSON file; - reads stdin').action(async options => {
|
|
33
|
+
if (Boolean(options.json) === Boolean(options.file)) throw new Error('Provide exactly one of --json or --file.');
|
|
34
|
+
let raw = options.json;
|
|
35
|
+
if (options.file === '-') {
|
|
36
|
+
const chunks = []; let bytes = 0;
|
|
37
|
+
for await (const chunk of process.stdin) {
|
|
38
|
+
bytes += chunk.length;
|
|
39
|
+
if (bytes > 16384) throw new Error('Project record input exceeds 16 KiB. Nothing was saved.');
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
}
|
|
42
|
+
raw = Buffer.concat(chunks).toString();
|
|
43
|
+
}
|
|
44
|
+
else if (options.file) raw = (await readSafeFile(project(), options.file, { maxBytes: 16384 })).toString();
|
|
45
|
+
if (Buffer.byteLength(raw) > 16384) throw new Error('Project record input exceeds 16 KiB. Nothing was saved.');
|
|
46
|
+
const record = await recordWork(project(), JSON.parse(raw));
|
|
47
|
+
await refreshWork(project());
|
|
48
|
+
console.log(JSON.stringify(record, null, 2));
|
|
49
|
+
});
|
|
50
|
+
program.command('check <id> [argv...]').requiredOption('--title <text>', 'What the check proves').requiredOption('--files <paths...>', 'All relevant source/test inputs')
|
|
51
|
+
.option('--environment <name>', 'local or external', 'local').option('--timeout <ms>', 'Time limit', '30000').action(async (id, command, options) => {
|
|
52
|
+
const result = await runWorkCheck(project(), { id, title: options.title, files: options.files, command, environment: options.environment, timeout_ms: Number(options.timeout) });
|
|
53
|
+
await refreshWork(project());
|
|
54
|
+
console.log(JSON.stringify(result, null, 2));
|
|
55
|
+
if (result.exit_code !== 0 || result.timed_out || !result.inputs_stable) process.exitCode = 1;
|
|
56
|
+
});
|
|
57
|
+
program.command('retract <id>').requiredOption('--revision <number>', 'Current record revision').option('--category <name>', 'record or check', 'record').action(async (id, options) => {
|
|
58
|
+
const result = await retractWork(project(), { id, expected_revision: Number(options.revision), category: options.category });
|
|
59
|
+
await refreshWork(project());
|
|
60
|
+
console.log(JSON.stringify(result));
|
|
61
|
+
});
|
|
62
|
+
program.exitOverride();
|
|
63
|
+
await program.parseAsync(argv, { from: 'user' });
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Parser and filesystem messages can contain snippets of damaged secret files.
|
|
2
|
+
// Only our fixed domain errors may pass through to the client.
|
|
3
|
+
export function workErrorMessage(error) {
|
|
4
|
+
if (error instanceof SyntaxError) return 'Invalid project handoff JSON. Original file was preserved; contents were not returned.';
|
|
5
|
+
if (error?.name === 'ZodError' || error instanceof TypeError) return 'Invalid project record or evidence. Check the schema; original data was preserved.';
|
|
6
|
+
if (error?.code && error.code !== 'ELOCKED') return 'Project operation failed. Check file access and command arguments locally; file contents were not returned.';
|
|
7
|
+
return error?.message || 'Project operation failed.';
|
|
8
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deliberately exposes only the connected project's continuation records.
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { workRoot, recordSchema, checkSchema, recordWork, retractWork, refreshWork, formatWork } from './store.js';
|
|
7
|
+
import { workErrorMessage } from './errors.js';
|
|
8
|
+
|
|
9
|
+
const project = await workRoot(process.env.MEMOIR_PROJECT_ROOT || process.cwd());
|
|
10
|
+
const server = new McpServer({ name: 'memoir-work', version: '1.0.0' });
|
|
11
|
+
const respond = handler => async args => {
|
|
12
|
+
try {
|
|
13
|
+
const value = await handler(args);
|
|
14
|
+
const view = await refreshWork(project);
|
|
15
|
+
return { content: [{ type: 'text', text: value ? JSON.stringify(value) : formatWork(view) }] };
|
|
16
|
+
} catch (error) {
|
|
17
|
+
// Never echo rejected input, which might contain credentials.
|
|
18
|
+
return { isError: true, content: [{ type: 'text', text: workErrorMessage(error) }] };
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
server.tool('memoir_work_resume', 'Read this project and branch: answered questions, decisions, next actions and checks with current input comparisons. Call before asking repeated questions or rerunning saved checks.', {}, respond(async () => null));
|
|
22
|
+
server.tool('memoir_work_record', 'Save a project-only goal, answer, decision or next action. Never save personal preferences or secrets. Read first; corrections require the current expected_revision. Source must identify the actual user statement or project evidence. This cannot claim a test passed.', { record: recordSchema }, respond(async ({ record }) => recordWork(project, record)));
|
|
23
|
+
// An MCP server runs with its own host privileges, not necessarily the coding
|
|
24
|
+
// client's terminal sandbox. Never turn this memory connection into a shell.
|
|
25
|
+
// Keep the old tool name to give existing clients a safe migration response.
|
|
26
|
+
server.tool('memoir_work_check', 'Command execution is disabled over MCP. Run memoir work check through the client’s normal terminal permission/sandbox route to capture execution evidence.', { check: checkSchema }, async () => ({ isError: true, content: [{ type: 'text', text: 'MCP command execution is disabled. Use the memoir work check CLI documented in project AGENTS.md through your normal terminal permissions. This memory connection does not grant shell access.' }] }));
|
|
27
|
+
server.tool('memoir_work_retract', 'Remove a mistaken record from the current handoff; its history remains locally for correction. Read its current revision first.', { id: z.string(), category: z.enum(['record', 'check']).default('record'), expected_revision: z.number().int() }, respond(async input => retractWork(project, input)));
|
|
28
|
+
await server.connect(new StdioServerTransport());
|