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/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 +210 -110
- package/src/utils/platform.js +0 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.1",
|
|
4
4
|
"mcpName": "io.github.camgitt/memoir",
|
|
5
5
|
"description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"start": "node bin/memoir.js",
|
|
31
31
|
"test": "node run-tests.mjs",
|
|
32
32
|
"test:legacy": "bash test-local.sh",
|
|
33
|
-
"prepublishOnly": "npm test",
|
|
33
|
+
"prepublishOnly": "node scripts/check-clean-for-publish.mjs && npm test",
|
|
34
34
|
"postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
|
|
35
35
|
},
|
|
36
36
|
"keywords": [
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"author": "camgitt",
|
|
74
74
|
"license": "MIT",
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
76
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
77
77
|
"boxen": "^7.1.1",
|
|
78
78
|
"chalk": "^5.3.0",
|
|
79
79
|
"commander": "^12.0.0",
|
package/src/adapters/index.js
CHANGED
|
@@ -4,18 +4,12 @@ import path from 'path';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { shouldIgnoreProject } from '../context/capture.js';
|
|
7
|
-
import { vscodeUserDir, vscodeGlobalStorage } from '../utils/platform.js';
|
|
8
7
|
|
|
9
8
|
const home = os.homedir();
|
|
10
9
|
|
|
11
10
|
const isWin = process.platform === 'win32';
|
|
12
11
|
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
13
12
|
|
|
14
|
-
// VS Code-family config dirs — resolved per-OS (incl. Linux) via platform.js.
|
|
15
|
-
const cursorUserDir = vscodeUserDir('Cursor');
|
|
16
|
-
const windsurfUserDir = vscodeUserDir('Windsurf');
|
|
17
|
-
const clineStorageDir = vscodeGlobalStorage('saoudrizwan.claude-dev');
|
|
18
|
-
|
|
19
13
|
export const adapters = [
|
|
20
14
|
{
|
|
21
15
|
name: 'Gemini CLI',
|
|
@@ -84,9 +78,13 @@ export const adapters = [
|
|
|
84
78
|
{
|
|
85
79
|
name: 'Cursor',
|
|
86
80
|
icon: '⚡',
|
|
87
|
-
source:
|
|
81
|
+
source: isWin
|
|
82
|
+
? path.join(appData, 'Cursor', 'User')
|
|
83
|
+
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User'),
|
|
88
84
|
filter: (src) => {
|
|
89
|
-
const cursorDir =
|
|
85
|
+
const cursorDir = isWin
|
|
86
|
+
? path.join(appData, 'Cursor', 'User')
|
|
87
|
+
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
|
|
90
88
|
const rel = path.relative(cursorDir, src);
|
|
91
89
|
if (src === cursorDir) return true;
|
|
92
90
|
const basename = path.basename(src);
|
|
@@ -119,9 +117,13 @@ export const adapters = [
|
|
|
119
117
|
{
|
|
120
118
|
name: 'Windsurf',
|
|
121
119
|
icon: '🏄',
|
|
122
|
-
source:
|
|
120
|
+
source: isWin
|
|
121
|
+
? path.join(appData, 'Windsurf', 'User')
|
|
122
|
+
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
|
|
123
123
|
filter: (src) => {
|
|
124
|
-
const windsurfDir =
|
|
124
|
+
const windsurfDir = isWin
|
|
125
|
+
? path.join(appData, 'Windsurf', 'User')
|
|
126
|
+
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User');
|
|
125
127
|
const rel = path.relative(windsurfDir, src);
|
|
126
128
|
if (src === windsurfDir) return true;
|
|
127
129
|
const basename = path.basename(src);
|
package/src/commands/autopush.js
CHANGED
|
@@ -13,9 +13,11 @@ import fs from 'fs-extra';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import os from 'os';
|
|
15
15
|
import { spawn } from 'child_process';
|
|
16
|
+
import { withSessionLock } from '../session/lock.js';
|
|
16
17
|
|
|
17
18
|
const home = os.homedir();
|
|
18
19
|
const STAMP_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp');
|
|
20
|
+
const STAMP_LOCK_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp.lock');
|
|
19
21
|
const DEBOUNCE_SECONDS_DEFAULT = 30;
|
|
20
22
|
|
|
21
23
|
export async function autopushCommand(options = {}) {
|
|
@@ -26,23 +28,35 @@ export async function autopushCommand(options = {}) {
|
|
|
26
28
|
await fs.ensureDir(path.dirname(STAMP_FILE));
|
|
27
29
|
} catch {}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// The debounce check ("read timestamp, compare elapsed, write new
|
|
32
|
+
// timestamp") is itself an unlocked check-then-act — two Stop hooks firing
|
|
33
|
+
// in the same window could both pass the debounce gate and both spawn a
|
|
34
|
+
// detached `memoir push`, racing each other against the git remote. Wrap
|
|
35
|
+
// the whole read+compare+stamp cycle in the same lock primitive
|
|
36
|
+
// state.js's mutators use (a dedicated lock file — this stamp is an
|
|
37
|
+
// unrelated concern from session.json itself).
|
|
38
|
+
const shouldRun = await withSessionLock(STAMP_LOCK_FILE, async () => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
let last = 0;
|
|
41
|
+
try {
|
|
42
|
+
const raw = await fs.readFile(STAMP_FILE, 'utf8');
|
|
43
|
+
last = parseInt(raw.trim(), 10) || 0;
|
|
44
|
+
} catch {}
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
const elapsed = (now - last) / 1000;
|
|
47
|
+
if (last && elapsed < debounce) {
|
|
48
|
+
if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Stamp BEFORE spawning so rapid repeat calls don't all race through.
|
|
53
|
+
try {
|
|
54
|
+
await fs.writeFile(STAMP_FILE, String(now));
|
|
55
|
+
} catch {}
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!shouldRun) return;
|
|
46
60
|
|
|
47
61
|
// Detach a background push. Parent exits immediately so Claude isn't blocked.
|
|
48
62
|
const memoirBin = process.argv[1]; // path to this same memoir CLI
|
package/src/commands/push.js
CHANGED
|
@@ -5,20 +5,72 @@ import os from 'os';
|
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import boxen from 'boxen';
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
8
9
|
import { getConfig, autoSetup } from '../config.js';
|
|
9
10
|
import { extractMemories, adapters } from '../adapters/index.js';
|
|
10
11
|
import { syncToLocal, syncToGit } from '../providers/index.js';
|
|
11
12
|
import inquirer from 'inquirer';
|
|
12
|
-
import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions } from '../context/capture.js';
|
|
13
|
+
import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
|
|
13
14
|
import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
|
|
14
15
|
import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
|
|
15
16
|
import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
|
|
16
17
|
import { scanWorkspace } from '../workspace/tracker.js';
|
|
17
18
|
import { promptActivate } from './activate.js';
|
|
18
|
-
import { paths as sessionPaths, readSession, addNote, recordSessionEnd } from '../session/state.js';
|
|
19
|
+
import { paths as sessionPaths, readSession, writeSession, mergeSessions, addNote, recordSessionEnd } from '../session/state.js';
|
|
20
|
+
import { migrateSessionData } from '../session/migrations.js';
|
|
21
|
+
import { withSessionLock } from '../session/lock.js';
|
|
19
22
|
import { renderSession } from '../session/render.js';
|
|
20
23
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
21
24
|
|
|
25
|
+
// Best-effort fetch of the CURRENT remote session.json, so push.js can merge
|
|
26
|
+
// before overwrite instead of blindly clobbering it (see below). Returns the
|
|
27
|
+
// remote session state (already migrated to SCHEMA_VERSION) or null if the
|
|
28
|
+
// remote is unreachable, this is the very first push (nothing there yet), or
|
|
29
|
+
// the remote backup is encrypted (best-effort only — we deliberately don't
|
|
30
|
+
// force an extra decrypt passphrase prompt mid-push; falls back to
|
|
31
|
+
// local-only in that case, exactly like an unreachable remote).
|
|
32
|
+
async function fetchRemoteSessionBestEffort(config) {
|
|
33
|
+
try {
|
|
34
|
+
if (config.provider === 'local' || config.provider?.includes?.('local')) {
|
|
35
|
+
const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir());
|
|
36
|
+
if (!resolvedDest) return null;
|
|
37
|
+
if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return null; // encrypted
|
|
38
|
+
const remotePath = path.join(resolvedDest, 'session.json');
|
|
39
|
+
if (!(await fs.pathExists(remotePath))) return null;
|
|
40
|
+
const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
|
|
41
|
+
const { state } = migrateSessionData(raw);
|
|
42
|
+
return state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (config.provider === 'git' || config.provider?.includes?.('git')) {
|
|
46
|
+
const repoUrl = config.gitRepo;
|
|
47
|
+
if (!repoUrl) return null;
|
|
48
|
+
const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
|
|
49
|
+
await fs.ensureDir(peekDir);
|
|
50
|
+
try {
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 30000 });
|
|
53
|
+
} catch {
|
|
54
|
+
// Unreachable, or this is the very first push (repo doesn't exist
|
|
55
|
+
// yet / is empty) — fall back to local-only.
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return null; // encrypted
|
|
59
|
+
const remotePath = path.join(peekDir, 'session.json');
|
|
60
|
+
if (!(await fs.pathExists(remotePath))) return null;
|
|
61
|
+
const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
|
|
62
|
+
const { state } = migrateSessionData(raw);
|
|
63
|
+
return state;
|
|
64
|
+
} finally {
|
|
65
|
+
await fs.remove(peekDir).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Never let a merge-fetch failure block the push.
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
// Recursively scan every staged file (the REAL tool memory/config files about
|
|
23
75
|
// to be uploaded — CLAUDE.md, .cursorrules, settings.json, project configs,
|
|
24
76
|
// etc.) for secrets. When `redact` is true, rewrite each offending file in
|
|
@@ -134,11 +186,20 @@ export async function pushCommand(options = {}) {
|
|
|
134
186
|
await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
|
|
135
187
|
await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
|
|
136
188
|
|
|
189
|
+
// Quality filter: auto-extracted decisions come from regex patterns
|
|
190
|
+
// that sometimes catch table cells, prose fragments, or truncated
|
|
191
|
+
// pasted-spec snippets. Run the SAME filter over parsed.decisions
|
|
192
|
+
// ONCE, before either persistence sink — previously persistDecisions()
|
|
193
|
+
// received the raw unfiltered list while only the session.json sink
|
|
194
|
+
// below filtered, so junk could reach session-decisions.md even after
|
|
195
|
+
// being rejected from session.json. Both sinks now agree on what's junk.
|
|
196
|
+
const qualityDecisions = parsed.decisions.filter(d => isQuality(String(d.value || '').trim()));
|
|
197
|
+
|
|
137
198
|
// Persist decisions to Claude's memory so they survive across sessions
|
|
138
199
|
let decisionCount = 0;
|
|
139
|
-
if (
|
|
200
|
+
if (qualityDecisions.length > 0) {
|
|
140
201
|
try {
|
|
141
|
-
decisionCount = persistDecisions(
|
|
202
|
+
decisionCount = persistDecisions(qualityDecisions);
|
|
142
203
|
} catch {}
|
|
143
204
|
}
|
|
144
205
|
|
|
@@ -150,25 +211,8 @@ export async function pushCommand(options = {}) {
|
|
|
150
211
|
const existingTexts = new Set(
|
|
151
212
|
current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
|
|
152
213
|
);
|
|
153
|
-
|
|
154
|
-
// that sometimes catch table cells or prose fragments. Keep only
|
|
155
|
-
// substantive-looking entries.
|
|
156
|
-
const isQuality = (text) => {
|
|
157
|
-
if (!text) return false;
|
|
158
|
-
if (text.length < 15) return false; // too short to be a real decision
|
|
159
|
-
if (text.length > 200) return false; // probably a snippet, not a decision
|
|
160
|
-
if (/\|/.test(text)) return false; // markdown table fragment
|
|
161
|
-
if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
|
|
162
|
-
if (!/[a-zA-Z]/.test(text)) return false; // no actual words
|
|
163
|
-
if (/\?/.test(text)) return false; // questions aren't decisions
|
|
164
|
-
if (/^(it|this|that|these|those|we|i|they|you|some|there|here|just|back|now|also)\b/i.test(text)) return false; // fragment
|
|
165
|
-
const words = text.split(/\s+/).length;
|
|
166
|
-
if (words < 3) return false; // less than 3 words isn't a decision
|
|
167
|
-
return true;
|
|
168
|
-
};
|
|
169
|
-
for (const d of parsed.decisions.slice(0, 10)) {
|
|
214
|
+
for (const d of qualityDecisions.slice(0, 10)) {
|
|
170
215
|
const text = String(d.value || '').trim();
|
|
171
|
-
if (!isQuality(text)) continue;
|
|
172
216
|
if (existingTexts.has(text.toLowerCase())) continue;
|
|
173
217
|
await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
|
|
174
218
|
}
|
|
@@ -227,11 +271,38 @@ export async function pushCommand(options = {}) {
|
|
|
227
271
|
// Workspace scan is best-effort
|
|
228
272
|
}
|
|
229
273
|
|
|
230
|
-
// Include session.json (continuity state) so it syncs across machines
|
|
274
|
+
// Include session.json (continuity state) so it syncs across machines.
|
|
275
|
+
//
|
|
276
|
+
// MERGE-BEFORE-OVERWRITE: this used to be a blind fs.copy() of the LOCAL
|
|
277
|
+
// session.json, and syncToGit/syncToLocal do a full-mirror overwrite of
|
|
278
|
+
// the remote (clone-or-init, delete every tracked file, copy the local
|
|
279
|
+
// staging dir wholesale over it, commit, push). Any machine that pushed
|
|
280
|
+
// without having restored first would silently and completely destroy
|
|
281
|
+
// whatever ANY OTHER machine had added to the remote in the interim —
|
|
282
|
+
// goals, next-actions, decisions, everything. Not an edge case: it's the
|
|
283
|
+
// default behavior of the most common operation in the tool (autopush
|
|
284
|
+
// fires after every single Claude Code response).
|
|
285
|
+
//
|
|
286
|
+
// Best-effort fetch the current remote session.json first, migrate it,
|
|
287
|
+
// and merge with mergeSessions (the same newest-timestamp-wins
|
|
288
|
+
// union-by-text function restore.js already uses) BEFORE writing the
|
|
289
|
+
// result to both the staging dir (for upload) and back to the local
|
|
290
|
+
// session.json (so this machine also gains whatever the remote had that
|
|
291
|
+
// it didn't) — symmetric with restore.js instead of a blind overwrite.
|
|
231
292
|
let sessionIncluded = false;
|
|
232
293
|
try {
|
|
233
294
|
if (await fs.pathExists(sessionPaths.session)) {
|
|
234
|
-
await
|
|
295
|
+
const remote = await fetchRemoteSessionBestEffort(config);
|
|
296
|
+
const local = await readSession();
|
|
297
|
+
const merged = remote ? mergeSessions(local, remote) : local;
|
|
298
|
+
if (remote) {
|
|
299
|
+
// Persist the merge locally too, inside the same lock every other
|
|
300
|
+
// session.json read-modify-write cycle uses.
|
|
301
|
+
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
302
|
+
await writeSession(merged);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
|
|
235
306
|
sessionIncluded = true;
|
|
236
307
|
}
|
|
237
308
|
} catch {
|
package/src/commands/restore.js
CHANGED
|
@@ -15,6 +15,7 @@ import { getSession } from '../cloud/auth.js';
|
|
|
15
15
|
import { unbundleToDir } from '../cloud/storage.js';
|
|
16
16
|
import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
|
|
17
17
|
import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
|
|
18
|
+
import { migrateSessionData } from '../session/migrations.js';
|
|
18
19
|
import { renderSession } from '../session/render.js';
|
|
19
20
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
20
21
|
|
|
@@ -128,7 +129,13 @@ export async function restoreCommand(options = {}) {
|
|
|
128
129
|
try {
|
|
129
130
|
const remoteSessionPath = path.join(stagingDir, 'session.json');
|
|
130
131
|
if (await fs.pathExists(remoteSessionPath)) {
|
|
131
|
-
|
|
132
|
+
// Route the remote backup through the same migrate-on-load path as
|
|
133
|
+
// the local file (migrateSessionData — pure, no I/O) rather than a
|
|
134
|
+
// raw JSON.parse, so an old-schema file from a lagging machine gets
|
|
135
|
+
// migrated up (or a too-new one safely degraded) BEFORE mergeSessions
|
|
136
|
+
// ever touches it. Symmetric with the push-side fix in push.js.
|
|
137
|
+
const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
|
|
138
|
+
const { state: remote } = migrateSessionData(rawRemote);
|
|
132
139
|
const local = await readSession();
|
|
133
140
|
const beforeMachines = Object.keys(local.machines || {}).length;
|
|
134
141
|
const merged = mergeSessions(local, remote);
|
package/src/commands/tidy.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from 'fs-extra';
|
|
11
11
|
import path from 'path';
|
|
12
|
+
import { appendEvent } from '../events/log.js';
|
|
12
13
|
|
|
13
14
|
export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
|
|
14
15
|
|
|
@@ -57,6 +58,15 @@ function inlineWeight(section) {
|
|
|
57
58
|
|
|
58
59
|
const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
|
|
59
60
|
|
|
61
|
+
// Informational-only schema marker for MEMORY.md itself (distinct from — and
|
|
62
|
+
// unrelated to — session.json's SCHEMA_VERSION). No enforcement/refusal
|
|
63
|
+
// logic: this file is human-edited markdown, so a strict gate would hurt UX,
|
|
64
|
+
// not help it. Appended as a single HTML comment line (invisible when
|
|
65
|
+
// rendered) only when tidyIndex actually rewrites the file, and only once —
|
|
66
|
+
// idempotent, never duplicated on repeat runs. Counted in newLineCount like
|
|
67
|
+
// any other line, so it never causes a silent budget overshoot.
|
|
68
|
+
const MEMORY_SCHEMA_MARKER = '<!-- memoir:schemaVersion 1 -->';
|
|
69
|
+
|
|
60
70
|
async function atomicWrite(filePath, content) {
|
|
61
71
|
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
62
72
|
await fs.writeFile(tmp, content);
|
|
@@ -123,10 +133,20 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
|
|
|
123
133
|
else out.push(...sections[i].lines);
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
if (!out.some(l => l.includes('memoir:schemaVersion'))) {
|
|
137
|
+
out.push(MEMORY_SCHEMA_MARKER);
|
|
138
|
+
}
|
|
139
|
+
|
|
126
140
|
const fm = `---\nname: Memory index archive (${stamp})\ndescription: Fat inline sections moved out of MEMORY.md to keep the loaded index under ${budgetLines} lines. Nothing deleted; pointers remain in MEMORY.md.\nmetadata:\n type: reference\n---\n`;
|
|
127
141
|
const base = priorArchive || fm;
|
|
128
142
|
if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
|
|
129
143
|
await atomicWrite(mdPath, out.join('\n'));
|
|
130
144
|
|
|
145
|
+
// Only reached when tidyIndex actually changed something (both earlier
|
|
146
|
+
// no-op paths — under budget, or over budget with nothing archivable —
|
|
147
|
+
// return before this point, and dryRun never writes). The event should
|
|
148
|
+
// mean "something happened," not "this function was called."
|
|
149
|
+
await appendEvent('tidy_ran', { archived_count: archived.length, from_lines: lineCount, to_lines: out.length });
|
|
150
|
+
|
|
131
151
|
return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile };
|
|
132
152
|
}
|
package/src/commands/why.js
CHANGED
|
@@ -27,7 +27,11 @@ function searchDecisions(decisions, query) {
|
|
|
27
27
|
|
|
28
28
|
export async function whyCommand(query) {
|
|
29
29
|
const state = await readSession();
|
|
30
|
-
|
|
30
|
+
// hidden:true is a tombstone (distinct from the live `rejected` field) —
|
|
31
|
+
// see scripts/cleanup-junk-decisions-2026-07.mjs. Excluded here so
|
|
32
|
+
// tombstoned junk isn't fully discoverable via `memoir why` even after
|
|
33
|
+
// being hidden from the pinned block.
|
|
34
|
+
const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
|
|
31
35
|
const matches = searchDecisions(decisions, query);
|
|
32
36
|
|
|
33
37
|
if (matches.length === 0) {
|
|
@@ -54,7 +58,10 @@ export async function whyCommand(query) {
|
|
|
54
58
|
console.log('\n' + lines.join('\n'));
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
// Exported for MCP tool
|
|
61
|
+
// Exported for MCP tool (memoir_why in mcp.js). Same hidden:true tombstone
|
|
62
|
+
// filter as whyCommand above — kept independent rather than relying solely
|
|
63
|
+
// on the caller, so this stays correct even if mcp.js's call chain changes.
|
|
58
64
|
export function findDecisions(state, query) {
|
|
59
|
-
|
|
65
|
+
const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
|
|
66
|
+
return searchDecisions(decisions, query);
|
|
60
67
|
}
|
package/src/context/capture.js
CHANGED
|
@@ -161,6 +161,48 @@ function looksLikeFragment(v) {
|
|
|
161
161
|
return false;
|
|
162
162
|
}
|
|
163
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
|
+
|
|
164
206
|
/**
|
|
165
207
|
* Extract durable decisions from session conversation.
|
|
166
208
|
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
@@ -201,12 +243,37 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
201
243
|
}
|
|
202
244
|
}
|
|
203
245
|
|
|
204
|
-
// 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;
|
|
205
263
|
for (const msg of userMessages) {
|
|
206
|
-
|
|
207
|
-
const rememberMatch =
|
|
264
|
+
const scope = msg.slice(0, 500);
|
|
265
|
+
const rememberMatch = scope.match(USER_NOTE_RE);
|
|
208
266
|
if (rememberMatch) {
|
|
209
|
-
|
|
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
|
+
}
|
|
210
277
|
}
|
|
211
278
|
}
|
|
212
279
|
|
|
@@ -231,6 +298,18 @@ export function resolveHomeMemoryDir(claudeSource) {
|
|
|
231
298
|
return path.join(projectsDir, homeKey, 'memory');
|
|
232
299
|
}
|
|
233
300
|
|
|
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
|
+
}
|
|
312
|
+
|
|
234
313
|
/**
|
|
235
314
|
* Write extracted decisions to Claude's persistent memory.
|
|
236
315
|
* This ensures decisions survive across sessions and machines.
|
|
@@ -277,10 +356,10 @@ type: project
|
|
|
277
356
|
|
|
278
357
|
# Decisions from coding sessions
|
|
279
358
|
${section}`;
|
|
280
|
-
|
|
359
|
+
writeFileAtomicSync(decisionsFile, content);
|
|
281
360
|
} else {
|
|
282
361
|
// Append to existing
|
|
283
|
-
|
|
362
|
+
writeFileAtomicSync(decisionsFile, existing.trimEnd() + '\n' + section);
|
|
284
363
|
}
|
|
285
364
|
|
|
286
365
|
// Ensure MEMORY.md references the decisions file
|
|
@@ -288,7 +367,7 @@ ${section}`;
|
|
|
288
367
|
const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
|
|
289
368
|
if (!memoryMd.includes('session-decisions.md')) {
|
|
290
369
|
const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
|
|
291
|
-
|
|
370
|
+
writeFileAtomicSync(memoryMdPath, memoryMd.trimEnd() + addition);
|
|
292
371
|
}
|
|
293
372
|
}
|
|
294
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
|
+
};
|