memoir-cli 3.9.0 → 3.10.1

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/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
- const matches = findDecisions(state, query);
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
  }
@@ -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 {
@@ -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
+ }
@@ -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
- const decisions = (state.current?.decisions || []).slice(0, MAX_RENDERED_DECISIONS);
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;