memoir-cli 3.9.0 → 3.10.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/package.json +3 -3
- package/src/adapters/index.js +12 -10
- package/src/commands/autopush.js +29 -15
- package/src/commands/push.js +95 -24
- package/src/commands/restore.js +8 -1
- package/src/commands/tidy.js +20 -0
- package/src/commands/why.js +10 -3
- package/src/context/capture.js +86 -7
- package/src/events/log.js +113 -0
- package/src/mcp.js +7 -1
- package/src/providers/index.js +10 -0
- package/src/session/inject.js +12 -0
- package/src/session/lock.js +102 -0
- package/src/session/migrations.js +98 -0
- package/src/session/render.js +7 -1
- package/src/session/state.js +189 -110
- package/src/utils/platform.js +0 -47
package/src/mcp.js
CHANGED
|
@@ -697,7 +697,13 @@ server.tool(
|
|
|
697
697
|
{ query: z.string().describe('Keyword or phrase to search in decision text, rationale, or rejected alternative') },
|
|
698
698
|
async ({ query }) => {
|
|
699
699
|
const state = await readSession();
|
|
700
|
-
|
|
700
|
+
// findDecisions() already filters hidden:true (tombstoned) decisions —
|
|
701
|
+
// this second filter is deliberate belt-and-suspenders so this tool
|
|
702
|
+
// handler stays correct even if findDecisions' internals change without
|
|
703
|
+
// that coupling being obvious. Same tombstone semantics as render.js's
|
|
704
|
+
// pinned block and why.js's CLI display: distinct from the live
|
|
705
|
+
// `rejected` field.
|
|
706
|
+
const matches = findDecisions(state, query).filter(d => !d?.hidden);
|
|
701
707
|
if (matches.length === 0) {
|
|
702
708
|
return { content: [{ type: 'text', text: `No decisions match "${query}".` }] };
|
|
703
709
|
}
|
package/src/providers/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
|
+
import { appendEvent } from '../events/log.js';
|
|
6
7
|
|
|
7
8
|
function sanitizeUrl(url) {
|
|
8
9
|
// Reject URLs with shell metacharacters
|
|
@@ -23,6 +24,7 @@ export async function syncToLocal(config, stagingDir, spinner) {
|
|
|
23
24
|
|
|
24
25
|
await fs.copy(stagingDir, resolvedDest);
|
|
25
26
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray(`(Saved to ${resolvedDest})`));
|
|
27
|
+
await appendEvent('sync_pushed', { provider: 'local' });
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export async function syncToGit(config, stagingDir, spinner) {
|
|
@@ -64,7 +66,15 @@ export async function syncToGit(config, stagingDir, spinner) {
|
|
|
64
66
|
execFileSync('git', ['push', repoUrl, 'main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 });
|
|
65
67
|
|
|
66
68
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
|
|
69
|
+
await appendEvent('sync_pushed', { provider: 'git' });
|
|
67
70
|
} catch (err) {
|
|
71
|
+
// Makes a silently-swallowed push failure (a non-fast-forward rejection
|
|
72
|
+
// from two racing pushes, a network error, bad credentials, etc.)
|
|
73
|
+
// visible in the event log instead of vanishing into the detached
|
|
74
|
+
// autopush child's ignored stdio. Deliberately no raw error text/repo
|
|
75
|
+
// URL in the payload — those can contain usernames/paths; type+provider
|
|
76
|
+
// is enough to know "pushes are failing" without leaking anything.
|
|
77
|
+
await appendEvent('sync_failed', { provider: 'git' });
|
|
68
78
|
if (err.message.includes('invalid characters')) throw err;
|
|
69
79
|
throw new Error('Failed to push to git repository. Ensure your credentials are configured and the repository exists.');
|
|
70
80
|
} finally {
|
package/src/session/inject.js
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'fs-extra';
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import os from 'os';
|
|
16
16
|
import { BLOCK_START, BLOCK_END } from './render.js';
|
|
17
|
+
import { appendEvent } from '../events/log.js';
|
|
17
18
|
|
|
18
19
|
const home = os.homedir();
|
|
19
20
|
const isWin = process.platform === 'win32';
|
|
@@ -79,6 +80,17 @@ export async function injectInto(targetPath, renderedBlock) {
|
|
|
79
80
|
await fs.writeFile(tmp, updated);
|
|
80
81
|
await fs.move(tmp, targetPath, { overwrite: true });
|
|
81
82
|
|
|
83
|
+
// One event per target (this function is called once per detected tool —
|
|
84
|
+
// up to ~4x for a single session update). Deliberate: each call here IS a
|
|
85
|
+
// successful write to one specific target file, and the payload is tiny
|
|
86
|
+
// (just the filename, no content), so per-tool visibility is worth the 4x
|
|
87
|
+
// over collapsing to one event per "session update." injectInto() only
|
|
88
|
+
// receives a raw path (callers loop over detectAvailableTargets() by
|
|
89
|
+
// value, discarding the tool-name key), so the filename itself
|
|
90
|
+
// (CLAUDE.md / memoir-session.mdc / memoir-session.md / GEMINI.md) is
|
|
91
|
+
// what's actually available here without a larger refactor.
|
|
92
|
+
await appendEvent('memory_written', { target: path.basename(targetPath) });
|
|
93
|
+
|
|
82
94
|
return { path: targetPath, created: !existed, replaced: existed && BLOCK_RE.test(content) };
|
|
83
95
|
}
|
|
84
96
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Lightweight file lock for a read-modify-write critical section, with NO
|
|
2
|
+
// new npm dependency.
|
|
3
|
+
//
|
|
4
|
+
// WHY: two independent Claude Code sessions (or two racing Stop hooks) can
|
|
5
|
+
// run against the same $HOME at once — each runs its own memoir-mcp stdio
|
|
6
|
+
// server / autopush invocation. Every session.json mutator in state.js does
|
|
7
|
+
// readSession() -> mutate in memory -> writeSession(). writeSession's
|
|
8
|
+
// tmp-then-rename only prevents a TORN write; it does not stop two
|
|
9
|
+
// concurrent processes from both reading the same on-disk snapshot,
|
|
10
|
+
// mutating independently, and having the second writeSession() silently and
|
|
11
|
+
// completely overwrite the first process's change. autopush.js's debounce
|
|
12
|
+
// check ("read timestamp, compare elapsed, write new timestamp") is the same
|
|
13
|
+
// class of unlocked check-then-act. This is a real, easily-triggered
|
|
14
|
+
// data-loss bug, not a theoretical one.
|
|
15
|
+
//
|
|
16
|
+
// MECHANISM: fs.openSync(lockPath, 'wx') is an atomic create-exclusive at
|
|
17
|
+
// the OS level — it throws EEXIST if the file already exists, so exactly one
|
|
18
|
+
// process can "win" the create at a time. Acquire retries on EEXIST with a
|
|
19
|
+
// short delay, up to a bounded total wait. Release deletes the lock file,
|
|
20
|
+
// wrapped in try/finally so a thrown error inside the critical section still
|
|
21
|
+
// releases the lock.
|
|
22
|
+
//
|
|
23
|
+
// STALE-LOCK RECOVERY: if the lock file is older than STALE_MS, we assume
|
|
24
|
+
// the process that created it crashed (or was killed) while holding it, and
|
|
25
|
+
// we remove it and proceed. This trades a small window of imperfect mutual
|
|
26
|
+
// exclusion for availability — appropriate for a local, single-user tool,
|
|
27
|
+
// where a permanently stuck lock from a crashed process is a worse failure
|
|
28
|
+
// mode than the rare double-write it might allow.
|
|
29
|
+
|
|
30
|
+
import fs from 'fs-extra';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
|
|
33
|
+
const RETRY_DELAY_MS = 50;
|
|
34
|
+
const MAX_WAIT_MS = 5000;
|
|
35
|
+
const STALE_MS = 30_000; // treat a lock older than this as abandoned
|
|
36
|
+
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acquire the exclusive lock at `lockPath`, run `fn`, then always release —
|
|
43
|
+
* even if `fn` throws. Returns whatever `fn` returns/resolves to.
|
|
44
|
+
*
|
|
45
|
+
* If the lock can't be acquired within MAX_WAIT_MS (and stale-lock recovery
|
|
46
|
+
* didn't free it up), proceeds WITHOUT the lock rather than hanging forever,
|
|
47
|
+
* printing one loud stderr warning — never blocks the caller indefinitely,
|
|
48
|
+
* and never throws just because the lock was contended.
|
|
49
|
+
*/
|
|
50
|
+
export async function withSessionLock(lockPath, fn) {
|
|
51
|
+
await fs.ensureDir(path.dirname(lockPath));
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
let fd = null;
|
|
54
|
+
let warned = false;
|
|
55
|
+
|
|
56
|
+
// eslint-disable-next-line no-constant-condition
|
|
57
|
+
while (true) {
|
|
58
|
+
try {
|
|
59
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
60
|
+
try { fs.writeSync(fd, String(process.pid)); } catch {}
|
|
61
|
+
break;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err.code !== 'EEXIST') throw err;
|
|
64
|
+
|
|
65
|
+
// Stale-lock recovery: the holder may have crashed. If the lock file
|
|
66
|
+
// is older than STALE_MS, remove it and retry the acquire immediately
|
|
67
|
+
// (no delay) rather than waiting out the full bounded window.
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.statSync(lockPath);
|
|
70
|
+
if (Date.now() - stat.mtimeMs > STALE_MS) {
|
|
71
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Lock file vanished between the failed open and this stat (the
|
|
76
|
+
// holder released it) — just retry the acquire.
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Date.now() - start > MAX_WAIT_MS) {
|
|
81
|
+
if (!warned) {
|
|
82
|
+
warned = true;
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`memoir: could not acquire lock at ${lockPath} after ${MAX_WAIT_MS}ms — proceeding without it (another memoir process may be mid-write).\n`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
fd = null;
|
|
88
|
+
break; // proceed without the lock rather than hang forever
|
|
89
|
+
}
|
|
90
|
+
await sleep(RETRY_DELAY_MS);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return await fn();
|
|
96
|
+
} finally {
|
|
97
|
+
if (fd !== null) {
|
|
98
|
+
try { fs.closeSync(fd); } catch {}
|
|
99
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure, no-I/O session-schema migration ladder for session.json.
|
|
2
|
+
//
|
|
3
|
+
// This module has NO file access and NO side effects — it's a plain data
|
|
4
|
+
// transform: migrateSessionData(parsedObject) -> { future, state }. That
|
|
5
|
+
// makes it safe to reuse everywhere a session object needs normalizing,
|
|
6
|
+
// not just when reading the local file:
|
|
7
|
+
// - state.js's readSession() calls it after JSON.parse-ing the local
|
|
8
|
+
// ~/.config/memoir/session.json.
|
|
9
|
+
// - push.js and restore.js call it on an already-parsed REMOTE session.json
|
|
10
|
+
// (fetched from another machine's backup) before merging it with the
|
|
11
|
+
// local session via mergeSessions — so a lagging machine's old-schema
|
|
12
|
+
// file, or a machine ahead on a newer schema, gets migrated/degraded
|
|
13
|
+
// consistently regardless of which code path touched it first.
|
|
14
|
+
//
|
|
15
|
+
// File-specific concerns — backing up the ORIGINAL on-disk file before a
|
|
16
|
+
// migration changes its data, and printing a one-time user-facing warning —
|
|
17
|
+
// belong to the caller that actually owns a real file (readSession()), not
|
|
18
|
+
// here. Remote data has no local file to back up; mergeSessions' own
|
|
19
|
+
// never-clobber semantics are the safety net there (a degraded remote read
|
|
20
|
+
// at worst contributes nothing to the merge, never destroys local data).
|
|
21
|
+
|
|
22
|
+
export const SCHEMA_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
// Per-version migration steps, keyed by the version being migrated FROM.
|
|
25
|
+
// Each step takes a state object at version N and returns one at N+1.
|
|
26
|
+
// Currently empty (identity ladder) since SCHEMA_VERSION is still 1 — this
|
|
27
|
+
// exists so a REAL future schema bump has a tested seam to hang a step off,
|
|
28
|
+
// rather than growing an ad hoc branch inside migrateSessionData itself.
|
|
29
|
+
//
|
|
30
|
+
// const MIGRATIONS = {
|
|
31
|
+
// 1: (state) => ({ ...state, version: 2, /* ...transform... */ }),
|
|
32
|
+
// };
|
|
33
|
+
const MIGRATIONS = {};
|
|
34
|
+
|
|
35
|
+
export function emptySession() {
|
|
36
|
+
return {
|
|
37
|
+
version: SCHEMA_VERSION,
|
|
38
|
+
created_at: new Date().toISOString(),
|
|
39
|
+
updated_at: new Date().toISOString(),
|
|
40
|
+
machines: {}, // { [machineId]: { label, last_seen } }
|
|
41
|
+
current: {
|
|
42
|
+
goals: [], // { text, machine_id, set_on }
|
|
43
|
+
next_actions: [], // { text, machine_id, added, completed? }
|
|
44
|
+
open_questions: [],// { text, machine_id, asked }
|
|
45
|
+
decisions: [], // { text, why?, rejected?, hidden?, hidden_at?, machine_id, date }
|
|
46
|
+
},
|
|
47
|
+
history: [], // { date, machine_id, summary, files_touched, duration_min? }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize an already-JSON.parsed session object to SCHEMA_VERSION.
|
|
53
|
+
*
|
|
54
|
+
* Returns { future, state }:
|
|
55
|
+
* - future: false, state: normalized object at SCHEMA_VERSION — either
|
|
56
|
+
* walked forward through the migration ladder from an older/missing
|
|
57
|
+
* version, or passed through unchanged (with defaults filled in for any
|
|
58
|
+
* missing fields) if already current.
|
|
59
|
+
* - future: true, state: a fresh, empty, valid session — returned when the
|
|
60
|
+
* input's version is NEWER than this build's SCHEMA_VERSION (the file
|
|
61
|
+
* came from a newer memoir install). We deliberately do not attempt to
|
|
62
|
+
* interpret an unknown future shape; the caller decides what to do with
|
|
63
|
+
* `future: true` (readSession() backs up the original + warns once).
|
|
64
|
+
*
|
|
65
|
+
* Never throws on malformed input — worst case, returns a fresh empty
|
|
66
|
+
* session (future: false), matching the existing "corrupted JSON" recovery
|
|
67
|
+
* behavior elsewhere in this codebase.
|
|
68
|
+
*/
|
|
69
|
+
export function migrateSessionData(raw) {
|
|
70
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
71
|
+
return { future: false, state: emptySession() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let version = typeof raw.version === 'number' && Number.isFinite(raw.version) ? raw.version : 0;
|
|
75
|
+
|
|
76
|
+
if (version > SCHEMA_VERSION) {
|
|
77
|
+
return { future: true, state: emptySession() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let state = raw;
|
|
81
|
+
while (version < SCHEMA_VERSION) {
|
|
82
|
+
const step = MIGRATIONS[version];
|
|
83
|
+
state = step ? step(state) : { ...state, version: version + 1 };
|
|
84
|
+
version += 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fresh = emptySession();
|
|
88
|
+
const normalized = {
|
|
89
|
+
...fresh,
|
|
90
|
+
...state,
|
|
91
|
+
version: SCHEMA_VERSION,
|
|
92
|
+
current: { ...fresh.current, ...(state?.current || {}) },
|
|
93
|
+
machines: { ...fresh.machines, ...(state?.machines || {}) },
|
|
94
|
+
history: Array.isArray(state?.history) ? state.history : [],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { future: false, state: normalized };
|
|
98
|
+
}
|
package/src/session/render.js
CHANGED
|
@@ -19,7 +19,13 @@ export function renderSession(state) {
|
|
|
19
19
|
const goals = (state.current?.goals || []).slice(0, MAX_RENDERED_GOALS);
|
|
20
20
|
const nexts = (state.current?.next_actions || []).slice(-MAX_RENDERED_NEXT).reverse();
|
|
21
21
|
const questions = (state.current?.open_questions || []).slice(-MAX_RENDERED_QUESTIONS).reverse();
|
|
22
|
-
|
|
22
|
+
// hidden:true is a tombstone (see scripts/cleanup-junk-decisions-2026-07.mjs)
|
|
23
|
+
// — distinct from the `rejected` field (which is a live, user-facing "the
|
|
24
|
+
// alternative we considered and rejected" string). Filtered here, and in
|
|
25
|
+
// why.js's CLI search+display and its MCP memoir_why handler, so a
|
|
26
|
+
// tombstoned decision is fully suppressed rather than just hidden from one
|
|
27
|
+
// of the three places decisions are read/displayed/searched.
|
|
28
|
+
const decisions = (state.current?.decisions || []).filter(d => !d?.hidden).slice(0, MAX_RENDERED_DECISIONS);
|
|
23
29
|
const history = (state.history || []).slice(0, MAX_RENDERED_HISTORY);
|
|
24
30
|
|
|
25
31
|
const everythingEmpty = !goals.length && !nexts.length && !questions.length && !decisions.length && !history.length;
|
package/src/session/state.js
CHANGED
|
@@ -9,13 +9,25 @@ import fs from 'fs-extra';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import crypto from 'crypto';
|
|
12
|
+
import { withSessionLock } from './lock.js';
|
|
13
|
+
import { SCHEMA_VERSION, migrateSessionData, emptySession } from './migrations.js';
|
|
14
|
+
// NOTE: events/log.js imports getMachineId FROM this module — this is a
|
|
15
|
+
// circular import, safe here because both appendEvent (used below) and
|
|
16
|
+
// getMachineId (used by events/log.js) are hoisted function declarations
|
|
17
|
+
// used only inside other functions' bodies, never at module-evaluation
|
|
18
|
+
// time. Verified working; see test-event-log.mjs.
|
|
19
|
+
import { appendEvent } from '../events/log.js';
|
|
12
20
|
|
|
13
21
|
const home = os.homedir();
|
|
14
22
|
const CONFIG_DIR = path.join(home, '.config', 'memoir');
|
|
15
23
|
const SESSION_PATH = path.join(CONFIG_DIR, 'session.json');
|
|
16
24
|
const MACHINE_ID_PATH = path.join(CONFIG_DIR, 'machine.id');
|
|
25
|
+
const SESSION_LOCK_PATH = path.join(CONFIG_DIR, 'session.json.lock');
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
// Re-exported for external consumers (e.g. test-session.mjs asserts against
|
|
28
|
+
// state.SCHEMA_VERSION) — the canonical constant now lives in migrations.js
|
|
29
|
+
// alongside the migration ladder it governs.
|
|
30
|
+
export { SCHEMA_VERSION, emptySession };
|
|
19
31
|
|
|
20
32
|
// Maximum items kept in each list before oldest entries rotate into history.
|
|
21
33
|
// Prevents unbounded growth of the live pinned block.
|
|
@@ -44,66 +56,107 @@ export async function getMachineId() {
|
|
|
44
56
|
return { id, label: os.hostname() };
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
// ── Schema ───────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
function emptySession() {
|
|
50
|
-
return {
|
|
51
|
-
version: SCHEMA_VERSION,
|
|
52
|
-
created_at: new Date().toISOString(),
|
|
53
|
-
updated_at: new Date().toISOString(),
|
|
54
|
-
machines: {}, // { [machineId]: { label, last_seen } }
|
|
55
|
-
current: {
|
|
56
|
-
goals: [], // { text, machine_id, set_on }
|
|
57
|
-
next_actions: [], // { text, machine_id, added, completed? }
|
|
58
|
-
open_questions: [],// { text, machine_id, asked }
|
|
59
|
-
decisions: [], // { text, why?, rejected?, machine_id, date }
|
|
60
|
-
},
|
|
61
|
-
history: [], // { date, machine_id, summary, files_touched, duration_min? }
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
59
|
// ── Read / write ─────────────────────────────────────────────────
|
|
60
|
+
//
|
|
61
|
+
// Forward-version guard: if session.json's version is NEWER than this
|
|
62
|
+
// build's SCHEMA_VERSION (the file came from a newer memoir install — e.g.
|
|
63
|
+
// another machine upgraded first and this one hasn't yet), readSession()
|
|
64
|
+
// backs up the original file (mirroring the corrupted-JSON quarantine
|
|
65
|
+
// pattern below) and returns a safe, empty-but-valid session instead of
|
|
66
|
+
// misinterpreting an unknown shape. This is centralized HERE, not in
|
|
67
|
+
// individual callers, so all ~20 call sites across mcp.js (8 MCP tool
|
|
68
|
+
// handlers), commands/session.js, commands/why.js, commands/auto-refresh.js,
|
|
69
|
+
// commands/push.js, commands/restore.js automatically get safe behavior
|
|
70
|
+
// with zero changes required at each call site — and critically, no MCP
|
|
71
|
+
// tool call is ever allowed to throw/crash because of a schema mismatch.
|
|
72
|
+
let warnedForwardVersion = false; // print the upgrade warning once per process, not once per call
|
|
73
|
+
|
|
74
|
+
// Opportunistic cleanup so the .corrupted-<ts> / .pre-migration-<ts> backup
|
|
75
|
+
// patterns don't accumulate forever on a machine that repeatedly hits
|
|
76
|
+
// either quarantine path. Keeps the N most recent of EACH pattern, deletes
|
|
77
|
+
// older ones. Best-effort — a cleanup failure never blocks the caller.
|
|
78
|
+
const MAX_BACKUPS_PER_PATTERN = 3;
|
|
79
|
+
function cleanupOldBackups(suffixPrefix) {
|
|
80
|
+
try {
|
|
81
|
+
const dir = path.dirname(SESSION_PATH);
|
|
82
|
+
const base = path.basename(SESSION_PATH); // "session.json"
|
|
83
|
+
const marker = `${base}.${suffixPrefix}-`;
|
|
84
|
+
const matches = fs.readdirSync(dir)
|
|
85
|
+
.filter((f) => f.startsWith(marker))
|
|
86
|
+
.map((f) => {
|
|
87
|
+
let mtime = 0;
|
|
88
|
+
try { mtime = fs.statSync(path.join(dir, f)).mtimeMs; } catch {}
|
|
89
|
+
return { name: f, mtime };
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
92
|
+
for (const f of matches.slice(MAX_BACKUPS_PER_PATTERN)) {
|
|
93
|
+
try { fs.unlinkSync(path.join(dir, f.name)); } catch {}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort — never block the caller.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
66
99
|
|
|
67
|
-
// Atomic read with graceful recovery from corrupted JSON
|
|
100
|
+
// Atomic read with graceful recovery from corrupted JSON AND from a
|
|
101
|
+
// too-new schema version.
|
|
68
102
|
export async function readSession() {
|
|
69
103
|
if (!await fs.pathExists(SESSION_PATH)) return emptySession();
|
|
70
104
|
|
|
105
|
+
let raw;
|
|
71
106
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
107
|
+
raw = await fs.readFile(SESSION_PATH, 'utf8');
|
|
108
|
+
} catch {
|
|
109
|
+
// Unreadable (permissions, race with a concurrent delete, etc.) —
|
|
110
|
+
// degrade to a safe empty session rather than throwing.
|
|
111
|
+
return emptySession();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
76
118
|
// Corrupted — preserve it for inspection, start fresh.
|
|
77
119
|
const backup = `${SESSION_PATH}.corrupted-${Date.now()}`;
|
|
78
120
|
try { await fs.copy(SESSION_PATH, backup); } catch {}
|
|
121
|
+
cleanupOldBackups('corrupted');
|
|
79
122
|
return emptySession();
|
|
80
123
|
}
|
|
124
|
+
|
|
125
|
+
const { future, state } = migrateSessionData(parsed);
|
|
126
|
+
|
|
127
|
+
if (future) {
|
|
128
|
+
const backup = `${SESSION_PATH}.pre-migration-${Date.now()}`;
|
|
129
|
+
try { await fs.copy(SESSION_PATH, backup); } catch {}
|
|
130
|
+
cleanupOldBackups('pre-migration');
|
|
131
|
+
if (!warnedForwardVersion) {
|
|
132
|
+
warnedForwardVersion = true;
|
|
133
|
+
try {
|
|
134
|
+
process.stderr.write(
|
|
135
|
+
`memoir: session.json is from a newer version of memoir than this install understands ` +
|
|
136
|
+
`(schema v${parsed?.version} > v${SCHEMA_VERSION}). It has been backed up to ${backup}. ` +
|
|
137
|
+
`Run: npm i -g memoir-cli@latest\n`
|
|
138
|
+
);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return state;
|
|
81
144
|
}
|
|
82
145
|
|
|
83
146
|
// Atomic write: write to tmp, rename. Prevents torn writes on crash.
|
|
147
|
+
// Unconditionally stamps version — every write lands at the CURRENT
|
|
148
|
+
// SCHEMA_VERSION, since it always passed through readSession/migrateSessionData
|
|
149
|
+
// (or emptySession()) to get here. Always called from within a locked
|
|
150
|
+
// critical section (see the mutators below and lock.js).
|
|
84
151
|
export async function writeSession(state) {
|
|
85
152
|
await fs.ensureDir(CONFIG_DIR);
|
|
153
|
+
state.version = SCHEMA_VERSION;
|
|
86
154
|
state.updated_at = new Date().toISOString();
|
|
87
155
|
const tmp = `${SESSION_PATH}.tmp-${process.pid}`;
|
|
88
156
|
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
|
|
89
157
|
await fs.move(tmp, SESSION_PATH, { overwrite: true });
|
|
90
158
|
}
|
|
91
159
|
|
|
92
|
-
function migrateIfNeeded(state) {
|
|
93
|
-
if (state && state.version === SCHEMA_VERSION) return state;
|
|
94
|
-
// Future versions: add migration steps here.
|
|
95
|
-
// For now, if version mismatch, merge defaults to fill gaps.
|
|
96
|
-
const fresh = emptySession();
|
|
97
|
-
return {
|
|
98
|
-
...fresh,
|
|
99
|
-
...state,
|
|
100
|
-
version: SCHEMA_VERSION,
|
|
101
|
-
current: { ...fresh.current, ...(state?.current || {}) },
|
|
102
|
-
machines: { ...fresh.machines, ...(state?.machines || {}) },
|
|
103
|
-
history: Array.isArray(state?.history) ? state.history : [],
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
160
|
// ── Machine registration ────────────────────────────────────────
|
|
108
161
|
|
|
109
162
|
async function touchMachine(state) {
|
|
@@ -117,98 +170,123 @@ async function touchMachine(state) {
|
|
|
117
170
|
|
|
118
171
|
// ── Mutators ────────────────────────────────────────────────────
|
|
119
172
|
|
|
173
|
+
// Every mutator below wraps its ENTIRE read -> mutate -> write cycle in
|
|
174
|
+
// withSessionLock — not just the write. Locking only the write would still
|
|
175
|
+
// allow two processes to both read the same stale snapshot before either
|
|
176
|
+
// writes; the read must be inside the lock too so the second process reads
|
|
177
|
+
// the FIRST process's already-written change rather than a stale copy.
|
|
178
|
+
|
|
120
179
|
export async function addGoal(text) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
180
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
181
|
+
const state = await readSession();
|
|
182
|
+
const machineId = await touchMachine(state);
|
|
183
|
+
state.current.goals.unshift({
|
|
184
|
+
text,
|
|
185
|
+
machine_id: machineId,
|
|
186
|
+
set_on: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
state.current.goals = state.current.goals.slice(0, MAX_GOALS);
|
|
189
|
+
await writeSession(state);
|
|
190
|
+
await appendEvent('goal_set', {}); // no PII/content — count-and-type only
|
|
191
|
+
return state;
|
|
127
192
|
});
|
|
128
|
-
state.current.goals = state.current.goals.slice(0, MAX_GOALS);
|
|
129
|
-
await writeSession(state);
|
|
130
|
-
return state;
|
|
131
193
|
}
|
|
132
194
|
|
|
133
195
|
export async function addNext(text) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
196
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
197
|
+
const state = await readSession();
|
|
198
|
+
const machineId = await touchMachine(state);
|
|
199
|
+
// Dedupe by text (case-insensitive)
|
|
200
|
+
const normalized = text.trim().toLowerCase();
|
|
201
|
+
const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized);
|
|
202
|
+
if (!exists) {
|
|
203
|
+
state.current.next_actions.push({
|
|
204
|
+
text,
|
|
205
|
+
machine_id: machineId,
|
|
206
|
+
added: new Date().toISOString(),
|
|
207
|
+
});
|
|
208
|
+
state.current.next_actions = state.current.next_actions.slice(-MAX_NEXT);
|
|
209
|
+
}
|
|
210
|
+
await writeSession(state);
|
|
211
|
+
return state;
|
|
212
|
+
});
|
|
149
213
|
}
|
|
150
214
|
|
|
151
215
|
export async function completeNext(textOrIndex) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
216
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
217
|
+
const state = await readSession();
|
|
218
|
+
await touchMachine(state);
|
|
219
|
+
let idx = -1;
|
|
220
|
+
if (typeof textOrIndex === 'number') {
|
|
221
|
+
idx = textOrIndex;
|
|
222
|
+
} else {
|
|
223
|
+
const normalized = String(textOrIndex).trim().toLowerCase();
|
|
224
|
+
idx = state.current.next_actions.findIndex(a => a.text.trim().toLowerCase().includes(normalized));
|
|
225
|
+
}
|
|
226
|
+
const completed = idx >= 0;
|
|
227
|
+
if (completed) {
|
|
228
|
+
state.current.next_actions.splice(idx, 1);
|
|
229
|
+
}
|
|
230
|
+
await writeSession(state);
|
|
231
|
+
// Only when something was actually completed — the event should mean
|
|
232
|
+
// "something happened," not "this function was called with no match."
|
|
233
|
+
if (completed) await appendEvent('next_completed', {});
|
|
234
|
+
return state;
|
|
235
|
+
});
|
|
166
236
|
}
|
|
167
237
|
|
|
168
238
|
export async function addNote(text, opts = {}) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
239
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
240
|
+
const state = await readSession();
|
|
241
|
+
const machineId = await touchMachine(state);
|
|
242
|
+
const decision = {
|
|
243
|
+
text,
|
|
244
|
+
machine_id: machineId,
|
|
245
|
+
date: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
if (opts.why) decision.why = opts.why;
|
|
248
|
+
if (opts.rejected) decision.rejected = opts.rejected;
|
|
249
|
+
state.current.decisions.unshift(decision);
|
|
250
|
+
state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
|
|
251
|
+
await writeSession(state);
|
|
252
|
+
// Count/booleans only — never the decision text itself.
|
|
253
|
+
await appendEvent('decision_captured', { has_why: !!opts.why, has_rejected: !!opts.rejected });
|
|
254
|
+
return state;
|
|
255
|
+
});
|
|
182
256
|
}
|
|
183
257
|
|
|
184
258
|
export async function addQuestion(text) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
259
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
260
|
+
const state = await readSession();
|
|
261
|
+
const machineId = await touchMachine(state);
|
|
262
|
+
state.current.open_questions.push({
|
|
263
|
+
text,
|
|
264
|
+
machine_id: machineId,
|
|
265
|
+
asked: new Date().toISOString(),
|
|
266
|
+
});
|
|
267
|
+
state.current.open_questions = state.current.open_questions.slice(-MAX_QUESTIONS);
|
|
268
|
+
await writeSession(state);
|
|
269
|
+
return state;
|
|
191
270
|
});
|
|
192
|
-
state.current.open_questions = state.current.open_questions.slice(-MAX_QUESTIONS);
|
|
193
|
-
await writeSession(state);
|
|
194
|
-
return state;
|
|
195
271
|
}
|
|
196
272
|
|
|
197
273
|
// Roll up the current state into a history entry. Use at session end / push.
|
|
198
274
|
// Does not clear `current` — these are "the working set," not per-session scratch.
|
|
199
275
|
export async function recordSessionEnd({ summary, filesTouched = [], durationMin = null } = {}) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
276
|
+
return withSessionLock(SESSION_LOCK_PATH, async () => {
|
|
277
|
+
const state = await readSession();
|
|
278
|
+
const machineId = await touchMachine(state);
|
|
279
|
+
state.history.unshift({
|
|
280
|
+
date: new Date().toISOString(),
|
|
281
|
+
machine_id: machineId,
|
|
282
|
+
summary: summary || '',
|
|
283
|
+
files_touched: filesTouched.slice(0, 20),
|
|
284
|
+
duration_min: durationMin,
|
|
285
|
+
});
|
|
286
|
+
state.history = state.history.slice(0, MAX_HISTORY);
|
|
287
|
+
await writeSession(state);
|
|
288
|
+
return state;
|
|
208
289
|
});
|
|
209
|
-
state.history = state.history.slice(0, MAX_HISTORY);
|
|
210
|
-
await writeSession(state);
|
|
211
|
-
return state;
|
|
212
290
|
}
|
|
213
291
|
|
|
214
292
|
// ── Cross-machine merge ─────────────────────────────────────────
|
|
@@ -293,4 +371,5 @@ export const paths = {
|
|
|
293
371
|
config: CONFIG_DIR,
|
|
294
372
|
session: SESSION_PATH,
|
|
295
373
|
machineId: MACHINE_ID_PATH,
|
|
374
|
+
sessionLock: SESSION_LOCK_PATH,
|
|
296
375
|
};
|