memoir-cli 3.8.1 → 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.
@@ -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
- export const SCHEMA_VERSION = 1;
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
- const raw = await fs.readFile(SESSION_PATH, 'utf8');
73
- const parsed = JSON.parse(raw);
74
- return migrateIfNeeded(parsed);
75
- } catch (err) {
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
- const state = await readSession();
122
- const machineId = await touchMachine(state);
123
- state.current.goals.unshift({
124
- text,
125
- machine_id: machineId,
126
- set_on: new Date().toISOString(),
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
- const state = await readSession();
135
- const machineId = await touchMachine(state);
136
- // Dedupe by text (case-insensitive)
137
- const normalized = text.trim().toLowerCase();
138
- const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized);
139
- if (!exists) {
140
- state.current.next_actions.push({
141
- text,
142
- machine_id: machineId,
143
- added: new Date().toISOString(),
144
- });
145
- state.current.next_actions = state.current.next_actions.slice(-MAX_NEXT);
146
- }
147
- await writeSession(state);
148
- return state;
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
- const state = await readSession();
153
- await touchMachine(state);
154
- let idx = -1;
155
- if (typeof textOrIndex === 'number') {
156
- idx = textOrIndex;
157
- } else {
158
- const normalized = String(textOrIndex).trim().toLowerCase();
159
- idx = state.current.next_actions.findIndex(a => a.text.trim().toLowerCase().includes(normalized));
160
- }
161
- if (idx >= 0) {
162
- state.current.next_actions.splice(idx, 1);
163
- }
164
- await writeSession(state);
165
- return state;
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
- const state = await readSession();
170
- const machineId = await touchMachine(state);
171
- const decision = {
172
- text,
173
- machine_id: machineId,
174
- date: new Date().toISOString(),
175
- };
176
- if (opts.why) decision.why = opts.why;
177
- if (opts.rejected) decision.rejected = opts.rejected;
178
- state.current.decisions.unshift(decision);
179
- state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
180
- await writeSession(state);
181
- return state;
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
- const state = await readSession();
186
- const machineId = await touchMachine(state);
187
- state.current.open_questions.push({
188
- text,
189
- machine_id: machineId,
190
- asked: new Date().toISOString(),
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
- const state = await readSession();
201
- const machineId = await touchMachine(state);
202
- state.history.unshift({
203
- date: new Date().toISOString(),
204
- machine_id: machineId,
205
- summary: summary || '',
206
- files_touched: filesTouched.slice(0, 20),
207
- duration_min: durationMin,
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
  };