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.
- package/README.md +20 -4
- package/package.json +4 -4
- package/src/commands/activate.js +20 -0
- package/src/commands/auto-refresh.js +27 -0
- package/src/commands/autopush.js +29 -15
- package/src/commands/push.js +95 -22
- package/src/commands/restore.js +8 -1
- package/src/commands/tidy.js +152 -0
- package/src/commands/why.js +25 -8
- package/src/context/capture.js +123 -36
- 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/context/capture.js
CHANGED
|
@@ -151,6 +151,58 @@ function parseLines(lines) {
|
|
|
151
151
|
return result;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Reject conversational fragments that loose regexes sometimes capture as
|
|
155
|
+
// "decisions" — questions, and clauses starting with a pronoun/filler word
|
|
156
|
+
// ("we pick this back up Monday", "it up at...", "some lenders may...").
|
|
157
|
+
function looksLikeFragment(v) {
|
|
158
|
+
if (!v) return true;
|
|
159
|
+
if (/\?/.test(v)) return true;
|
|
160
|
+
if (/^(it|this|that|these|those|we|i|they|you|he|she|some|there|here|just|back|now|also)\b/i.test(v)) return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Quality gate for auto-extracted decisions before they're written to EITHER
|
|
165
|
+
// persistence sink (session-decisions.md via persistDecisions, or session.json
|
|
166
|
+
// via addNote — see push.js, which now runs this once over parsed.decisions
|
|
167
|
+
// before both call sites). Auto-extraction is regex-based and occasionally
|
|
168
|
+
// produces prose fragments, markdown-table cells, or truncated pasted-spec
|
|
169
|
+
// snippets — this rejects the shapes that look like junk rather than a real
|
|
170
|
+
// decision.
|
|
171
|
+
//
|
|
172
|
+
// 2026-07: two real junk decisions made it into a live session.json because
|
|
173
|
+
// the user-note regex (see extractDecisions below) matched mid-paragraph
|
|
174
|
+
// inside long pasted spec/prompt text, and one of them was a hard 150-char
|
|
175
|
+
// truncation with an unbalanced closing paren. The regex is now anchored to
|
|
176
|
+
// message/line start (see below), which independently prevents both from
|
|
177
|
+
// matching at all — these two extra checks are defense-in-depth for the
|
|
178
|
+
// other, unanchored pattern branches (rename/tech/design/stack) that can
|
|
179
|
+
// still match mid-message.
|
|
180
|
+
export function isQuality(text) {
|
|
181
|
+
if (!text) return false;
|
|
182
|
+
if (text.length < 15) return false; // too short to be a real decision
|
|
183
|
+
if (text.length > 200) return false; // probably a snippet, not a decision
|
|
184
|
+
if (/\|/.test(text)) return false; // markdown table fragment
|
|
185
|
+
if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
|
|
186
|
+
if (!/[a-zA-Z]/.test(text)) return false; // no actual words
|
|
187
|
+
if (looksLikeFragment(text)) return false; // question, or pronoun/filler-start fragment
|
|
188
|
+
const words = text.split(/\s+/).length;
|
|
189
|
+
if (words < 3) return false; // less than 3 words isn't a decision
|
|
190
|
+
|
|
191
|
+
// Unbalanced parens/brackets — a hallmark of a regex capture that got cut
|
|
192
|
+
// off mid-parenthetical (real junk: "...only gain is Y)" with no opener,
|
|
193
|
+
// because the opening "(" was in the text BEFORE the capture started).
|
|
194
|
+
const opens = (text.match(/[(\[]/g) || []).length;
|
|
195
|
+
const closes = (text.match(/[)\]]/g) || []).length;
|
|
196
|
+
if (opens !== closes) return false;
|
|
197
|
+
|
|
198
|
+
// Suspiciously long AND doesn't end in sentence-ending punctuation or a
|
|
199
|
+
// closing quote — another truncation signature (a capture cut off mid-word
|
|
200
|
+
// or mid-sentence by a regex length cap rather than ending naturally).
|
|
201
|
+
if (text.length >= 140 && !/[.!?"')\]]$/.test(text)) return false;
|
|
202
|
+
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
154
206
|
/**
|
|
155
207
|
* Extract durable decisions from session conversation.
|
|
156
208
|
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
@@ -168,16 +220,19 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
168
220
|
// Tech choices
|
|
169
221
|
{ regex: /(?:let'?s|we(?:'ll| will| should)?|going to|decided to)\s+use\s+([A-Z][a-zA-Z0-9_./-]+)\s+(?:for|instead|as|to)/gi, type: 'tech' },
|
|
170
222
|
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
171
|
-
// Architecture / design
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
223
|
+
// Architecture / design — require an explicit decision verb and a capitalized
|
|
224
|
+
// target. Bare "pick/choose" caught conversational fragments as decisions.
|
|
225
|
+
{ regex: /(?:decided|settled|going|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
226
|
+
// Stack choices — require a capitalized, tech-looking value, not a prose
|
|
227
|
+
// fragment ("backend is just throwing it away" used to leak through).
|
|
228
|
+
{ regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' },
|
|
175
229
|
];
|
|
176
230
|
|
|
177
231
|
for (const { regex, type } of patterns) {
|
|
178
232
|
let match;
|
|
179
233
|
while ((match = regex.exec(allText)) !== null) {
|
|
180
234
|
const value = match[1].trim().replace(/["']+$/, '');
|
|
235
|
+
if (looksLikeFragment(value)) continue;
|
|
181
236
|
if (value.length > 2 && value.length < 80) {
|
|
182
237
|
// Avoid duplicates
|
|
183
238
|
const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
|
|
@@ -188,12 +243,37 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
188
243
|
}
|
|
189
244
|
}
|
|
190
245
|
|
|
191
|
-
// Look for explicit "remember this" instructions from the user
|
|
246
|
+
// Look for explicit "remember this" instructions from the user.
|
|
247
|
+
//
|
|
248
|
+
// Anchored to message/line start ((?:^|\n) immediately before optional
|
|
249
|
+
// indentation and an optional "please") — unlike the pattern branches
|
|
250
|
+
// above, this used to match ANYWHERE in the message, which meant a phrase
|
|
251
|
+
// like "note that" or "keep in mind that" appearing mid-sentence inside a
|
|
252
|
+
// long pasted spec/prompt got misread as an explicit remember-instruction.
|
|
253
|
+
// Requiring it to start the message (or a line within it) means only a
|
|
254
|
+
// genuine top-of-message instruction matches, not incidental prose deep in
|
|
255
|
+
// pasted content.
|
|
256
|
+
//
|
|
257
|
+
// Only scanned within the first ~500 chars of the message: a short
|
|
258
|
+
// "Remember that X." followed by a long paste in the SAME turn must still
|
|
259
|
+
// be captured (the instruction is still at message start), but a trigger
|
|
260
|
+
// phrase that only occurs later/mid-document in a long paste is excluded
|
|
261
|
+
// — it was never an instruction to begin with.
|
|
262
|
+
const USER_NOTE_RE = /(?:^|\n)[ \t]*(?:please\s+)?(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i;
|
|
192
263
|
for (const msg of userMessages) {
|
|
193
|
-
|
|
194
|
-
const rememberMatch =
|
|
264
|
+
const scope = msg.slice(0, 500);
|
|
265
|
+
const rememberMatch = scope.match(USER_NOTE_RE);
|
|
195
266
|
if (rememberMatch) {
|
|
196
|
-
|
|
267
|
+
const capturedRaw = rememberMatch[1];
|
|
268
|
+
// The capture group is capped at 150 chars. Hitting that cap exactly is
|
|
269
|
+
// a truncation signature — real junk in the wild was a parenthetical
|
|
270
|
+
// cut off mid-thought with an unbalanced closing paren. Reject rather
|
|
271
|
+
// than keep a truncated tail.
|
|
272
|
+
const hitCap = capturedRaw.length === 150;
|
|
273
|
+
const value = capturedRaw.trim();
|
|
274
|
+
if (!hitCap && !looksLikeFragment(value)) {
|
|
275
|
+
decisions.push({ type: 'user-note', value, context: msg.slice(0, 120) });
|
|
276
|
+
}
|
|
197
277
|
}
|
|
198
278
|
}
|
|
199
279
|
|
|
@@ -201,37 +281,44 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
201
281
|
}
|
|
202
282
|
|
|
203
283
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
284
|
+
* Resolve the HOME-level memory dir (~/.claude/projects/<home-key>/memory) —
|
|
285
|
+
* the one that matches the user's home path encoding, not a sub-project.
|
|
286
|
+
* Returns null if none exists. Shared by persistDecisions + lean-memory tidy.
|
|
206
287
|
*/
|
|
207
|
-
export function
|
|
208
|
-
if (!decisions || decisions.length === 0) return 0;
|
|
209
|
-
|
|
288
|
+
export function resolveHomeMemoryDir(claudeSource) {
|
|
210
289
|
const claudeDir = claudeSource || path.join(home, '.claude');
|
|
211
290
|
const projectsDir = path.join(claudeDir, 'projects');
|
|
212
|
-
|
|
291
|
+
// Canonical home-key path ONLY. We deliberately do NOT fall back to "shortest
|
|
292
|
+
// dir that has a memory/ subfolder" — on a shared machine that could silently
|
|
293
|
+
// target a different project's (or teammate's) memory. Callers create the dir
|
|
294
|
+
// if needed; tidy safely no-ops when MEMORY.md is absent.
|
|
295
|
+
const homeKey = process.platform === 'win32'
|
|
296
|
+
? home.replace(/\\/g, '-').replace(/:/g, '-')
|
|
297
|
+
: '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
298
|
+
return path.join(projectsDir, homeKey, 'memory');
|
|
299
|
+
}
|
|
213
300
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
301
|
+
// Atomic write (sync): write to a pid-scoped tmp file, then rename over the
|
|
302
|
+
// target. Matches the tmp-then-rename idiom used elsewhere in this codebase
|
|
303
|
+
// (state.js's writeSession, inject.js's injectInto) — prevents a torn/partial
|
|
304
|
+
// file if the process crashes mid-write. persistDecisions stays synchronous
|
|
305
|
+
// (its one caller in push.js doesn't await it), so this uses the sync
|
|
306
|
+
// fs-extra APIs rather than switching the whole call chain to async.
|
|
307
|
+
function writeFileAtomicSync(targetPath, content) {
|
|
308
|
+
const tmp = `${targetPath}.tmp-${process.pid}`;
|
|
309
|
+
fs.writeFileSync(tmp, content);
|
|
310
|
+
fs.moveSync(tmp, targetPath, { overwrite: true });
|
|
311
|
+
}
|
|
222
312
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (entries.length === 0) return 0;
|
|
230
|
-
// Shortest dir name is most likely the home key (not a sub-project)
|
|
231
|
-
const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0];
|
|
232
|
-
memDir = path.join(projectsDir, homeEntry.name, 'memory');
|
|
233
|
-
}
|
|
313
|
+
/**
|
|
314
|
+
* Write extracted decisions to Claude's persistent memory.
|
|
315
|
+
* This ensures decisions survive across sessions and machines.
|
|
316
|
+
*/
|
|
317
|
+
export function persistDecisions(decisions, claudeSource) {
|
|
318
|
+
if (!decisions || decisions.length === 0) return 0;
|
|
234
319
|
|
|
320
|
+
const memDir = resolveHomeMemoryDir(claudeSource);
|
|
321
|
+
if (!memDir) return 0;
|
|
235
322
|
fs.mkdirSync(memDir, { recursive: true });
|
|
236
323
|
const decisionsFile = path.join(memDir, 'session-decisions.md');
|
|
237
324
|
const memoryMdPath = path.join(memDir, 'MEMORY.md');
|
|
@@ -269,10 +356,10 @@ type: project
|
|
|
269
356
|
|
|
270
357
|
# Decisions from coding sessions
|
|
271
358
|
${section}`;
|
|
272
|
-
|
|
359
|
+
writeFileAtomicSync(decisionsFile, content);
|
|
273
360
|
} else {
|
|
274
361
|
// Append to existing
|
|
275
|
-
|
|
362
|
+
writeFileAtomicSync(decisionsFile, existing.trimEnd() + '\n' + section);
|
|
276
363
|
}
|
|
277
364
|
|
|
278
365
|
// Ensure MEMORY.md references the decisions file
|
|
@@ -280,7 +367,7 @@ ${section}`;
|
|
|
280
367
|
const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
|
|
281
368
|
if (!memoryMd.includes('session-decisions.md')) {
|
|
282
369
|
const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
|
|
283
|
-
|
|
370
|
+
writeFileAtomicSync(memoryMdPath, memoryMd.trimEnd() + addition);
|
|
284
371
|
}
|
|
285
372
|
}
|
|
286
373
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Additive, size-bounded, crash-safe JSONL event log.
|
|
2
|
+
//
|
|
3
|
+
// Target: ~/.config/memoir/events.jsonl — one JSON object per line:
|
|
4
|
+
// { ts, type, machine_id, ...minimal metadata }
|
|
5
|
+
//
|
|
6
|
+
// PRIVACY: this must never become a second, unfiltered copy of sensitive
|
|
7
|
+
// user data. NEVER log raw decision/note/goal TEXT content — only counts,
|
|
8
|
+
// ids, booleans, and short enum-like type strings. Every call site in this
|
|
9
|
+
// codebase that calls appendEvent() is expected to honor that; review any
|
|
10
|
+
// new call site against it.
|
|
11
|
+
//
|
|
12
|
+
// CRASH-SAFE: pure fs.appendFileSync (O_APPEND) for the actual write —
|
|
13
|
+
// never a read-modify-write on this file, so a crash mid-write can at worst
|
|
14
|
+
// leave a truncated LAST line, never corrupt earlier ones.
|
|
15
|
+
//
|
|
16
|
+
// SIZE-BOUNDED: rotates at MAX_BYTES — events.jsonl -> .1 -> .2, oldest
|
|
17
|
+
// generation beyond MAX_ROTATIONS is deleted.
|
|
18
|
+
//
|
|
19
|
+
// LOCKED ROTATE-THEN-APPEND: the size-check-and-maybe-rotate is itself a
|
|
20
|
+
// check-then-act sequence that would race under concurrent processes
|
|
21
|
+
// exactly like the session.json bug Commit 4 fixed (two processes both see
|
|
22
|
+
// "under the cap," both append, one rotates mid-write, etc.). Rather than
|
|
23
|
+
// threading through whichever *other* lock happens to be held at each of
|
|
24
|
+
// the 6+ call sites (some of which, like sync_pushed/sync_failed, have no
|
|
25
|
+
// adjacent lock at all), this uses ONE small dedicated lock
|
|
26
|
+
// (events.jsonl.lock) around every rotate-then-append, uniformly. Simpler
|
|
27
|
+
// and always-safe, at the cost of a little lock contention on an
|
|
28
|
+
// infrequent, cheap operation — a deliberate simplification over coupling
|
|
29
|
+
// to each caller's own lock.
|
|
30
|
+
//
|
|
31
|
+
// NEVER BREAKS THE CALLER: appendEvent() catches everything internally and
|
|
32
|
+
// never throws. The primary operation it's logging (writeSession,
|
|
33
|
+
// injectInto, push, etc.) must always succeed or fail on its own merits,
|
|
34
|
+
// never because event logging failed.
|
|
35
|
+
|
|
36
|
+
import fs from 'fs-extra';
|
|
37
|
+
import path from 'path';
|
|
38
|
+
import os from 'os';
|
|
39
|
+
import { getMachineId } from '../session/state.js';
|
|
40
|
+
import { withSessionLock } from '../session/lock.js';
|
|
41
|
+
|
|
42
|
+
const CONFIG_DIR = process.platform === 'win32'
|
|
43
|
+
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'memoir')
|
|
44
|
+
: path.join(os.homedir(), '.config', 'memoir');
|
|
45
|
+
const EVENTS_PATH = path.join(CONFIG_DIR, 'events.jsonl');
|
|
46
|
+
const EVENTS_LOCK_PATH = path.join(CONFIG_DIR, 'events.jsonl.lock');
|
|
47
|
+
|
|
48
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
49
|
+
const MAX_ROTATIONS = 2; // keep events.jsonl.1 and .2; older generations are dropped
|
|
50
|
+
|
|
51
|
+
let cachedMachineId = null;
|
|
52
|
+
async function machineId() {
|
|
53
|
+
if (cachedMachineId) return cachedMachineId;
|
|
54
|
+
try {
|
|
55
|
+
const { id } = await getMachineId();
|
|
56
|
+
cachedMachineId = id;
|
|
57
|
+
} catch {
|
|
58
|
+
cachedMachineId = 'unknown';
|
|
59
|
+
}
|
|
60
|
+
return cachedMachineId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Rotate events.jsonl -> .1 -> .2, drop anything beyond MAX_ROTATIONS. Must
|
|
64
|
+
// only be called from within the events lock (see appendEvent) — this
|
|
65
|
+
// function itself does no locking.
|
|
66
|
+
function rotateIfNeeded() {
|
|
67
|
+
try {
|
|
68
|
+
if (!fs.existsSync(EVENTS_PATH)) return;
|
|
69
|
+
const stat = fs.statSync(EVENTS_PATH);
|
|
70
|
+
if (stat.size < MAX_BYTES) return;
|
|
71
|
+
|
|
72
|
+
// Shift existing generations up (.1 -> .2 -> dropped), oldest first.
|
|
73
|
+
for (let i = MAX_ROTATIONS; i >= 1; i--) {
|
|
74
|
+
const src = `${EVENTS_PATH}.${i}`;
|
|
75
|
+
if (!fs.existsSync(src)) continue;
|
|
76
|
+
if (i === MAX_ROTATIONS) {
|
|
77
|
+
fs.removeSync(src); // oldest generation, drop it
|
|
78
|
+
} else {
|
|
79
|
+
fs.moveSync(src, `${EVENTS_PATH}.${i + 1}`, { overwrite: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fs.moveSync(EVENTS_PATH, `${EVENTS_PATH}.1`, { overwrite: true });
|
|
83
|
+
} catch {
|
|
84
|
+
// Rotation failure must never block an append.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Append one JSON event line. See file header for the privacy contract
|
|
90
|
+
* (counts/ids/booleans/enum-strings only, never raw content) and the
|
|
91
|
+
* crash-safety / size-bound / locking guarantees.
|
|
92
|
+
*
|
|
93
|
+
* Always safe to call and await — never throws, never rejects.
|
|
94
|
+
*/
|
|
95
|
+
export async function appendEvent(type, payload = {}) {
|
|
96
|
+
try {
|
|
97
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
98
|
+
const id = await machineId();
|
|
99
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), type, machine_id: id, ...payload }) + '\n';
|
|
100
|
+
|
|
101
|
+
await withSessionLock(EVENTS_LOCK_PATH, async () => {
|
|
102
|
+
rotateIfNeeded();
|
|
103
|
+
fs.appendFileSync(EVENTS_PATH, line);
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
// Never break the caller.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const paths = {
|
|
111
|
+
events: EVENTS_PATH,
|
|
112
|
+
eventsLock: EVENTS_LOCK_PATH,
|
|
113
|
+
};
|
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;
|