memoir-cli 3.12.0 → 3.14.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 +128 -137
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +50 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +18 -7
- package/src/commands/cloud.js +55 -4
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/push.js +156 -161
- package/src/commands/recall.js +1 -1
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +11 -9
- package/src/commands/validate.js +3 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +126 -32
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +105 -152
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +165 -70
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +200 -33
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
package/src/commands/why.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import boxen from 'boxen';
|
|
7
|
-
import {
|
|
7
|
+
import { visibleMemory } from '../memory/scope.js';
|
|
8
|
+
import { readSession, allDecisions } from '../session/state.js';
|
|
8
9
|
|
|
9
10
|
function searchDecisions(decisions, query) {
|
|
10
11
|
if (!query) return decisions;
|
|
@@ -31,7 +32,7 @@ export async function whyCommand(query) {
|
|
|
31
32
|
// see scripts/cleanup-junk-decisions-2026-07.mjs. Excluded here so
|
|
32
33
|
// tombstoned junk isn't fully discoverable via `memoir why` even after
|
|
33
34
|
// being hidden from the pinned block.
|
|
34
|
-
const decisions = (state
|
|
35
|
+
const decisions = allDecisions(state).filter(d => visibleMemory(d));
|
|
35
36
|
const matches = searchDecisions(decisions, query);
|
|
36
37
|
|
|
37
38
|
if (matches.length === 0) {
|
|
@@ -62,6 +63,6 @@ export async function whyCommand(query) {
|
|
|
62
63
|
// filter as whyCommand above — kept independent rather than relying solely
|
|
63
64
|
// on the caller, so this stays correct even if mcp.js's call chain changes.
|
|
64
65
|
export function findDecisions(state, query) {
|
|
65
|
-
const decisions = (state
|
|
66
|
+
const decisions = allDecisions(state).filter(d => visibleMemory(d));
|
|
66
67
|
return searchDecisions(decisions, query);
|
|
67
68
|
}
|
package/src/config.js
CHANGED
|
@@ -115,48 +115,17 @@ export async function deleteProfile(name) {
|
|
|
115
115
|
await saveConfig(raw);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
//
|
|
118
|
+
// First use stays local. Remote destinations require explicit configuration;
|
|
119
|
+
// no account lookup, repository creation, or upload happens as a side effect.
|
|
119
120
|
export async function autoSetup() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
username = execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8' }).trim();
|
|
127
|
-
} catch {}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (!username) return null; // Can't auto-setup without a username
|
|
131
|
-
|
|
132
|
-
const repo = 'ai-memory';
|
|
133
|
-
const gitRepo = `https://github.com/${username}/${repo}.git`;
|
|
134
|
-
|
|
135
|
-
// Try to create the repo if it doesn't exist (best-effort)
|
|
136
|
-
try {
|
|
137
|
-
execFileSync('gh', ['repo', 'view', `${username}/${repo}`], { stdio: 'ignore', timeout: 5000 });
|
|
138
|
-
} catch {
|
|
139
|
-
try {
|
|
140
|
-
execFileSync('gh', ['repo', 'create', `${username}/${repo}`, '--private', '--description', 'AI memory backup (memoir-cli)'], { stdio: 'ignore', timeout: 10000 });
|
|
141
|
-
} catch {
|
|
142
|
-
// If gh isn't available, user will need to create repo manually — that's fine, syncToGit will handle it
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const config = {
|
|
147
|
-
version: 2,
|
|
148
|
-
activeProfile: 'default',
|
|
149
|
-
profiles: {
|
|
150
|
-
default: {
|
|
151
|
-
provider: 'git',
|
|
152
|
-
gitRepo,
|
|
153
|
-
encrypt: false // Skip encryption for zero-config — user can enable later with `memoir encrypt`
|
|
154
|
-
}
|
|
155
|
-
}
|
|
121
|
+
if (await fs.pathExists(CONFIG_FILE)) throw new Error('An existing configuration could not be resolved. Check the selected profile or repair the configuration; it was not overwritten.');
|
|
122
|
+
const profile = {
|
|
123
|
+
provider: 'local',
|
|
124
|
+
localPath: path.join(CONFIG_DIR, 'backups'),
|
|
125
|
+
encrypt: Boolean(process.env.MEMOIR_PASSPHRASE),
|
|
156
126
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return config.profiles.default;
|
|
127
|
+
await saveConfig({ version: 2, activeProfile: 'default', profiles: { default: profile } });
|
|
128
|
+
return profile;
|
|
160
129
|
}
|
|
161
130
|
|
|
162
131
|
export async function getGeminiApiKey() {
|
package/src/context/capture.js
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
4
5
|
import { scanForSecrets, redactSecrets } from '../security/scanner.js';
|
|
5
6
|
|
|
6
7
|
const home = os.homedir();
|
|
7
8
|
|
|
9
|
+
// Terminal colour codes leak into transcripts through <local-command-stdout>
|
|
10
|
+
// blocks (a `/model` switch prints "\x1b[2m…"). Strip before anything is
|
|
11
|
+
// persisted — a handoff is read by a human and a model, not a terminal.
|
|
12
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
13
|
+
export function stripAnsi(s) {
|
|
14
|
+
return String(s || '').replace(ANSI_RE, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Claude Code delivers its own machinery as `user` turns: <command-name> /
|
|
18
|
+
// <command-args> / <local-command-stdout> around slash commands, the
|
|
19
|
+
// <local-command-caveat> (isMeta) that precedes them, <task-notification>s
|
|
20
|
+
// from background agents, hook output. A person's message never starts
|
|
21
|
+
// with an XML-ish tag; these always do. Live proof: the author's 2026-09-04
|
|
22
|
+
// handoff listed "/model", its caveat and its ANSI-coloured stdout as the
|
|
23
|
+
// three things he "was working on".
|
|
24
|
+
const MACHINERY_RE = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
|
|
25
|
+
export function isMachineryMessage(s) {
|
|
26
|
+
return MACHINERY_RE.test(String(s || ''));
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
/**
|
|
9
30
|
* Find all Claude session files, sorted newest first
|
|
10
31
|
*/
|
|
@@ -69,7 +90,6 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
|
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
function parseLines(lines) {
|
|
72
|
-
const assistantTexts = [];
|
|
73
93
|
const result = {
|
|
74
94
|
sessionId: null,
|
|
75
95
|
slug: null,
|
|
@@ -96,25 +116,41 @@ function parseLines(lines) {
|
|
|
96
116
|
if (!result.firstTimestamp && obj.timestamp) result.firstTimestamp = obj.timestamp;
|
|
97
117
|
if (obj.timestamp) result.lastTimestamp = obj.timestamp;
|
|
98
118
|
|
|
99
|
-
// User messages — redact secrets
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
// User messages — redact secrets. Skip the tool's own machinery (see
|
|
120
|
+
// isMachineryMessage) and isMeta turns; strip terminal colour codes.
|
|
121
|
+
if (obj.type === 'user' && obj.message?.content && !obj.isMeta) {
|
|
122
|
+
const content = typeof obj.message.content === 'string' ? stripAnsi(obj.message.content) : '';
|
|
123
|
+
if (content.length > 3 && !isMachineryMessage(content)) {
|
|
103
124
|
result.userMessages.push(redactSecrets(content));
|
|
104
125
|
}
|
|
126
|
+
// Tool results ride inside USER turns as tool_result blocks (there is
|
|
127
|
+
// no top-level type:"tool_result" in a Claude transcript — the old
|
|
128
|
+
// branch that looked for one never matched, so "Issues I ran into"
|
|
129
|
+
// was always empty). Only blocks the tool flagged is_error count,
|
|
130
|
+
// and only when they carry a line that names the failure — a bare
|
|
131
|
+
// "Exit code 1" from a grep with no matches is not an issue.
|
|
132
|
+
if (Array.isArray(obj.message.content)) {
|
|
133
|
+
for (const block of obj.message.content) {
|
|
134
|
+
if (!block || block.type !== 'tool_result' || !block.is_error) continue;
|
|
135
|
+
const text = typeof block.content === 'string'
|
|
136
|
+
? block.content
|
|
137
|
+
: (Array.isArray(block.content) ? block.content.map((c) => c?.text || '').join('\n') : '');
|
|
138
|
+
const line = stripAnsi(text).split('\n').map((l) => l.trim())
|
|
139
|
+
.find((l) => l && l.length < 200 && /error|fail|exception|cannot|not found|denied|refused|traceback|fatal|unexpected/i.test(l));
|
|
140
|
+
if (line) result.errors.push(redactSecrets(line));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
105
143
|
}
|
|
106
144
|
|
|
107
|
-
// Tool uses
|
|
145
|
+
// Tool uses from assistant turns. Assistant PROSE is deliberately not
|
|
146
|
+
// collected: it used to feed extractDecisions, so the model's own
|
|
147
|
+
// "let's use Redis for caching" minted a decision the user never made
|
|
148
|
+
// (agent-memory-atlas issue #7, and two content-free rows in the
|
|
149
|
+
// author's store on 2026-09-04). Decisions come from the user's words
|
|
150
|
+
// or from explicit memoir_note / `memoir note` — never inferred from
|
|
151
|
+
// what the assistant said.
|
|
108
152
|
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
109
153
|
for (const block of obj.message.content) {
|
|
110
|
-
if (block.type === 'text' && block.text) {
|
|
111
|
-
// Capture assistant text for decision extraction (limit size)
|
|
112
|
-
// Redacted like every other untrusted input (user :95, bash :125,
|
|
113
|
-
// errors :138) — captured decisions flow into session.json, CLAUDE.md
|
|
114
|
-
// and the git backup, none of which get a later secret scan.
|
|
115
|
-
if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text));
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
154
|
if (block.type !== 'tool_use') continue;
|
|
119
155
|
const name = block.name;
|
|
120
156
|
const input = block.input || {};
|
|
@@ -140,24 +176,14 @@ function parseLines(lines) {
|
|
|
140
176
|
}
|
|
141
177
|
}
|
|
142
178
|
|
|
143
|
-
// Errors from tool results
|
|
144
|
-
if (obj.type === 'tool_result' && obj.message?.content) {
|
|
145
|
-
const content = typeof obj.message.content === 'string' ? obj.message.content : '';
|
|
146
|
-
if (content.includes('Error') || content.includes('error') || content.includes('FAIL')) {
|
|
147
|
-
const errorLine = content.split('\n').find(l => /error|fail/i.test(l));
|
|
148
|
-
if (errorLine && errorLine.length < 200) {
|
|
149
|
-
result.errors.push(redactSecrets(errorLine.trim()));
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
result.filesWritten = [...result.filesWritten];
|
|
156
182
|
result.filesRead = [...result.filesRead];
|
|
157
183
|
result.errors = [...new Set(result.errors)].slice(0, 10);
|
|
158
184
|
|
|
159
|
-
// Extract decisions from user
|
|
160
|
-
result.decisions = extractDecisions(result.userMessages
|
|
185
|
+
// Extract decisions from the user's messages only (see above).
|
|
186
|
+
result.decisions = extractDecisions(result.userMessages);
|
|
161
187
|
|
|
162
188
|
return result;
|
|
163
189
|
}
|
|
@@ -218,9 +244,9 @@ export function isQuality(text) {
|
|
|
218
244
|
* Extract durable decisions from session conversation.
|
|
219
245
|
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
220
246
|
*/
|
|
221
|
-
function extractDecisions(userMessages
|
|
247
|
+
function extractDecisions(userMessages) {
|
|
222
248
|
const decisions = [];
|
|
223
|
-
const allText =
|
|
249
|
+
const allText = userMessages.join('\n');
|
|
224
250
|
|
|
225
251
|
// Patterns that indicate a decision was made
|
|
226
252
|
const patterns = [
|
|
@@ -248,6 +274,12 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
248
274
|
while ((match = regex.exec(allText)) !== null) {
|
|
249
275
|
const value = match[1].trim().replace(/["']+$/, '');
|
|
250
276
|
if (looksLikeFragment(value)) continue;
|
|
277
|
+
// The keyword half of the rename/tech patterns is case-insensitive
|
|
278
|
+
// (`i` flag) but the captured TARGET must be a proper noun — a product,
|
|
279
|
+
// a library, a name. Without this check the flag also lower-cased the
|
|
280
|
+
// [A-Z] anchor: "the name is settled" minted the value "settled" and
|
|
281
|
+
// "rename the app from …" minted "from" (both real rows, 2026-09-04).
|
|
282
|
+
if (!/^[A-Z]/.test(value)) continue;
|
|
251
283
|
if (value.length > 2 && value.length < 80) {
|
|
252
284
|
// Avoid duplicates
|
|
253
285
|
const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
|
|
@@ -466,6 +498,64 @@ export function promoteMemoriesToGlobal() {
|
|
|
466
498
|
return promoted;
|
|
467
499
|
}
|
|
468
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Files changed in the session's repository according to git: every file in
|
|
503
|
+
* a commit made since the session started, plus the current uncommitted
|
|
504
|
+
* changes. The transcript only knows about Edit/Write tool calls — a session
|
|
505
|
+
* that shipped through Bash heredocs, subagents or `git commit` reports
|
|
506
|
+
* "Files I changed: None" (27 of the author's last 40 handoffs, including a
|
|
507
|
+
* 15-hour session that shipped a product rename). Git is the honest source.
|
|
508
|
+
* Best effort: no repo, no git binary, or a slow repo → [].
|
|
509
|
+
*/
|
|
510
|
+
export function gitChangedFiles(cwd, sinceIso, { timeoutMs = 4000, cap = 200 } = {}) {
|
|
511
|
+
if (!cwd) return [];
|
|
512
|
+
const run = (args) => execFileSync('git', ['-C', cwd, ...args], {
|
|
513
|
+
encoding: 'utf8',
|
|
514
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
515
|
+
timeout: timeoutMs,
|
|
516
|
+
});
|
|
517
|
+
let root;
|
|
518
|
+
try { root = run(['rev-parse', '--show-toplevel']).trim(); } catch { return []; }
|
|
519
|
+
if (!root) return [];
|
|
520
|
+
|
|
521
|
+
const files = new Set();
|
|
522
|
+
try {
|
|
523
|
+
const args = ['log', '--name-only', '--format=', '--no-merges', '-n', '200'];
|
|
524
|
+
if (sinceIso) args.push(`--since=${sinceIso}`);
|
|
525
|
+
for (const line of run(args).split('\n')) {
|
|
526
|
+
const f = line.trim();
|
|
527
|
+
if (f) files.add(f);
|
|
528
|
+
}
|
|
529
|
+
} catch {}
|
|
530
|
+
try {
|
|
531
|
+
for (const line of run(['status', '--porcelain', '--untracked-files=normal']).split('\n')) {
|
|
532
|
+
if (line.length < 4) continue;
|
|
533
|
+
let f = line.slice(3).trim();
|
|
534
|
+
const arrow = f.indexOf(' -> ');
|
|
535
|
+
if (arrow >= 0) f = f.slice(arrow + 4);
|
|
536
|
+
f = f.replace(/^"|"$/g, '');
|
|
537
|
+
if (f && !f.endsWith('/')) files.add(f);
|
|
538
|
+
}
|
|
539
|
+
} catch {}
|
|
540
|
+
return [...files].slice(0, cap).map((f) => path.join(root, f));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Merge git's view of what changed into parsed.filesWritten (in place).
|
|
545
|
+
* Called by push/snapshot after parseSession; kept separate so parseSession
|
|
546
|
+
* stays a pure function of the transcript (and stays testable without git).
|
|
547
|
+
*/
|
|
548
|
+
export function enrichWithGit(parsed) {
|
|
549
|
+
try {
|
|
550
|
+
const fromGit = gitChangedFiles(parsed.cwd, parsed.firstTimestamp);
|
|
551
|
+
if (fromGit.length) {
|
|
552
|
+
parsed.filesWritten = [...new Set([...(parsed.filesWritten || []), ...fromGit])];
|
|
553
|
+
parsed.gitFiles = fromGit.length;
|
|
554
|
+
}
|
|
555
|
+
} catch {}
|
|
556
|
+
return parsed;
|
|
557
|
+
}
|
|
558
|
+
|
|
469
559
|
/**
|
|
470
560
|
* Generate a concise handoff markdown from parsed session
|
|
471
561
|
* This is what gets injected into the AI tool on the other machine
|
|
@@ -493,10 +583,13 @@ export function generateContextHandoff(parsed) {
|
|
|
493
583
|
return fp;
|
|
494
584
|
};
|
|
495
585
|
|
|
496
|
-
// Filter meaningful user messages
|
|
586
|
+
// Filter meaningful user messages. The LAST few are what "continue where
|
|
587
|
+
// I left off" needs — the first eight of a fifteen-hour session are stale.
|
|
497
588
|
const meaningful = parsed.userMessages
|
|
498
|
-
.filter(m => m.length > 10 && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
|
|
499
|
-
.map(m => m.
|
|
589
|
+
.filter(m => m.length > 10 && !isMachineryMessage(m) && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
|
|
590
|
+
.map(m => stripAnsi(m).replace(/\s+/g, ' ').trim())
|
|
591
|
+
.map(m => m.length > 150 ? m.slice(0, 150) + '...' : m)
|
|
592
|
+
.slice(-8);
|
|
500
593
|
|
|
501
594
|
// Build a concise, actionable handoff
|
|
502
595
|
let md = `---
|
|
@@ -511,11 +604,12 @@ type: project
|
|
|
511
604
|
> Session: ${duration} | Branch: \`${parsed.gitBranch || 'unknown'}\` | Project: \`${cwd}\`
|
|
512
605
|
|
|
513
606
|
## What I was working on
|
|
514
|
-
${meaningful.length > 0 ? meaningful.
|
|
607
|
+
${meaningful.length > 0 ? meaningful.map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
|
|
515
608
|
|
|
516
609
|
## Files I changed
|
|
517
610
|
${parsed.filesWritten.length > 0
|
|
518
611
|
? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
|
|
612
|
+
+ (parsed.filesWritten.length > 15 ? `\n- …and ${parsed.filesWritten.length - 15} more` : '')
|
|
519
613
|
: '_None_'}
|
|
520
614
|
`;
|
|
521
615
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Handoff files: ~/.config/memoir/handoffs/<timestamp>-claude.md + latest.md.
|
|
2
|
+
//
|
|
3
|
+
// One is written on EVERY autopush (the Stop hook fires after each response),
|
|
4
|
+
// so without a bound the directory grows by ~50 files a day forever — 3,944
|
|
5
|
+
// files / 15MB on the author's machine before this module existed, none of
|
|
6
|
+
// them ever read (only `memoir resume` reads, and it reads latest.md).
|
|
7
|
+
// Keep a window: the newest HANDOFF_KEEP_MAX files that are also younger
|
|
8
|
+
// than HANDOFF_KEEP_DAYS. latest.md is always kept. Pruning is best-effort
|
|
9
|
+
// and never fails the write that triggered it.
|
|
10
|
+
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
|
|
15
|
+
export const HANDOFF_KEEP_DAYS = 14;
|
|
16
|
+
export const HANDOFF_KEEP_MAX = 200;
|
|
17
|
+
|
|
18
|
+
// Resolved at call time (not module load) so tests can shim $HOME.
|
|
19
|
+
export function localHandoffDir() {
|
|
20
|
+
return path.join(os.homedir(), '.config', 'memoir', 'handoffs');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handoffFilename(now = new Date()) {
|
|
24
|
+
return `${now.toISOString().replace(/[:.]/g, '-').slice(0, 19)}-claude.md`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write `content` as a timestamped handoff + latest.md into every dir in
|
|
29
|
+
* `dirs` (default: the local handoff dir), then prune the local dir.
|
|
30
|
+
* Returns the filename used, so callers that also stage a copy for upload
|
|
31
|
+
* use the same name locally and remotely.
|
|
32
|
+
*/
|
|
33
|
+
export async function saveHandoff(content, { dirs, filename } = {}) {
|
|
34
|
+
const name = filename || handoffFilename();
|
|
35
|
+
const targets = dirs && dirs.length ? dirs : [localHandoffDir()];
|
|
36
|
+
for (const dir of targets) {
|
|
37
|
+
await fs.ensureDir(dir);
|
|
38
|
+
await fs.writeFile(path.join(dir, name), content);
|
|
39
|
+
await fs.writeFile(path.join(dir, 'latest.md'), content);
|
|
40
|
+
}
|
|
41
|
+
try { await pruneHandoffs(localHandoffDir()); } catch {}
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delete handoff files beyond the window. Returns how many were removed.
|
|
47
|
+
* Order is by mtime, newest first; a file survives only if it is within the
|
|
48
|
+
* first `keepMax` AND younger than `keepDays`.
|
|
49
|
+
*/
|
|
50
|
+
export async function pruneHandoffs(dir, { keepDays = HANDOFF_KEEP_DAYS, keepMax = HANDOFF_KEEP_MAX, now = Date.now() } = {}) {
|
|
51
|
+
let names;
|
|
52
|
+
try { names = await fs.readdir(dir); } catch { return 0; }
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
if (name === 'latest.md' || !name.endsWith('.md')) continue;
|
|
56
|
+
try {
|
|
57
|
+
const st = await fs.stat(path.join(dir, name));
|
|
58
|
+
entries.push({ name, mtime: st.mtimeMs });
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
62
|
+
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
|
63
|
+
let removed = 0;
|
|
64
|
+
for (let i = 0; i < entries.length; i++) {
|
|
65
|
+
if (i < keepMax && entries[i].mtime >= cutoff) continue;
|
|
66
|
+
try {
|
|
67
|
+
await fs.remove(path.join(dir, entries[i].name));
|
|
68
|
+
removed++;
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
return removed;
|
|
72
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// "Is memoir actually being used?" — a pure summary over events.jsonl lines.
|
|
2
|
+
//
|
|
3
|
+
// The audit that motivated it (2026-09-04) had to grep Claude transcripts to
|
|
4
|
+
// learn that the store took 75 writes for every 10 reads in two weeks: the
|
|
5
|
+
// log recorded every write and sync but not one recall. Reads are logged
|
|
6
|
+
// since 3.13.0 (mcp_tool_used / cli_command, names only), so this can now
|
|
7
|
+
// answer from the log alone. Pure and synchronous: hand it lines, get counts.
|
|
8
|
+
|
|
9
|
+
const MCP_READS = new Set(['memoir_recall', 'memoir_why', 'memoir_read']);
|
|
10
|
+
const MCP_WRITES = new Set(['memoir_remember', 'memoir_note', 'memoir_add_next', 'memoir_complete_next', 'memoir_set_goal', 'memoir_forget', 'memoir_consolidate']);
|
|
11
|
+
const CLI_READS = new Set(['recall', 'why']);
|
|
12
|
+
const CLI_WRITES = new Set(['note', 'next', 'goal', 'done', 'forget', 'consolidate']);
|
|
13
|
+
|
|
14
|
+
export function emptySummary() {
|
|
15
|
+
return {
|
|
16
|
+
events: 0,
|
|
17
|
+
reads: 0,
|
|
18
|
+
writes: 0,
|
|
19
|
+
by_tool: {}, // { memoir_recall: 9, ... } and CLI commands as 'cli:recall'
|
|
20
|
+
decisions_captured: 0,
|
|
21
|
+
next_completed: 0,
|
|
22
|
+
next_parked: 0,
|
|
23
|
+
goals_completed: 0,
|
|
24
|
+
sync_pushed: 0,
|
|
25
|
+
sync_failed: 0,
|
|
26
|
+
sync_fail_reasons: {},
|
|
27
|
+
sync_ms: [], // durations of successful pushes that recorded one
|
|
28
|
+
first_ts: null,
|
|
29
|
+
last_ts: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string[]|string} lines events.jsonl content or its lines
|
|
35
|
+
* @param {{ sinceMs?: number, now?: number }} opts window (default: everything)
|
|
36
|
+
*/
|
|
37
|
+
export function summarizeEvents(lines, { sinceMs = 0, now = Date.now() } = {}) {
|
|
38
|
+
const s = emptySummary();
|
|
39
|
+
const cutoff = sinceMs > 0 ? now - sinceMs : 0;
|
|
40
|
+
const arr = Array.isArray(lines) ? lines : String(lines || '').split('\n');
|
|
41
|
+
for (const raw of arr) {
|
|
42
|
+
const line = raw.trim();
|
|
43
|
+
if (!line) continue;
|
|
44
|
+
let e;
|
|
45
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
46
|
+
const t = Date.parse(e.ts || '');
|
|
47
|
+
if (cutoff && (!Number.isFinite(t) || t < cutoff)) continue;
|
|
48
|
+
s.events++;
|
|
49
|
+
if (Number.isFinite(t)) {
|
|
50
|
+
if (!s.first_ts || t < Date.parse(s.first_ts)) s.first_ts = e.ts;
|
|
51
|
+
if (!s.last_ts || t > Date.parse(s.last_ts)) s.last_ts = e.ts;
|
|
52
|
+
}
|
|
53
|
+
switch (e.type) {
|
|
54
|
+
case 'mcp_tool_used': {
|
|
55
|
+
const tool = String(e.tool || '');
|
|
56
|
+
s.by_tool[tool] = (s.by_tool[tool] || 0) + 1;
|
|
57
|
+
if (MCP_READS.has(tool)) s.reads++;
|
|
58
|
+
else if (MCP_WRITES.has(tool)) s.writes++;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'cli_command': {
|
|
62
|
+
const cmd = String(e.command || '');
|
|
63
|
+
const key = `cli:${cmd}`;
|
|
64
|
+
s.by_tool[key] = (s.by_tool[key] || 0) + 1;
|
|
65
|
+
if (CLI_READS.has(cmd)) s.reads++;
|
|
66
|
+
else if (CLI_WRITES.has(cmd)) s.writes++;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'decision_captured': s.decisions_captured++; break;
|
|
70
|
+
case 'next_completed': s.next_completed++; break;
|
|
71
|
+
case 'next_parked': s.next_parked += Number(e.count) || 1; break;
|
|
72
|
+
case 'goal_completed': s.goals_completed++; break;
|
|
73
|
+
case 'sync_pushed':
|
|
74
|
+
s.sync_pushed++;
|
|
75
|
+
if (Number.isFinite(e.ms)) s.sync_ms.push(e.ms);
|
|
76
|
+
break;
|
|
77
|
+
case 'sync_failed': {
|
|
78
|
+
s.sync_failed++;
|
|
79
|
+
const r = String(e.reason || 'unrecorded');
|
|
80
|
+
s.sync_fail_reasons[r] = (s.sync_fail_reasons[r] || 0) + 1;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
default: break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function median(nums) {
|
|
90
|
+
if (!nums.length) return null;
|
|
91
|
+
const a = [...nums].sort((x, y) => x - y);
|
|
92
|
+
const mid = Math.floor(a.length / 2);
|
|
93
|
+
return a.length % 2 ? a[mid] : Math.round((a[mid - 1] + a[mid]) / 2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Plain-text lines for `memoir status` (no colour; the caller styles). */
|
|
97
|
+
export function formatSummaryLines(s, { days = 7 } = {}) {
|
|
98
|
+
const out = [];
|
|
99
|
+
// Only tools that read or write memory make the top list — `push`,
|
|
100
|
+
// `autopush`, `status` are plumbing, not usage.
|
|
101
|
+
const isMemoryTool = (k) => k.startsWith('cli:') ? (CLI_READS.has(k.slice(4)) || CLI_WRITES.has(k.slice(4))) : (MCP_READS.has(k) || MCP_WRITES.has(k));
|
|
102
|
+
const top = Object.entries(s.by_tool).filter(([k]) => isMemoryTool(k)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4)
|
|
103
|
+
.map(([k, v]) => `${k.replace(/^memoir_/, '').replace(/^cli:/, 'cli ')} ${v}`).join(', ');
|
|
104
|
+
if (s.reads === 0 && s.writes === 0) {
|
|
105
|
+
out.push(`Last ${days} days: no memory reads or writes recorded${s.events ? '' : ' (reads are logged since 3.13.0)'}`);
|
|
106
|
+
} else {
|
|
107
|
+
out.push(`Memory read ${s.reads}× · written ${s.writes}×${top ? ` (${top})` : ''}`);
|
|
108
|
+
}
|
|
109
|
+
if (s.sync_pushed || s.sync_failed) {
|
|
110
|
+
const reasons = Object.entries(s.sync_fail_reasons).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
111
|
+
.map(([k, v]) => `${k} ${v}`).join(', ');
|
|
112
|
+
const med = median(s.sync_ms);
|
|
113
|
+
out.push(`Sync ${s.sync_pushed} ok / ${s.sync_failed} failed${reasons ? ` (${reasons})` : ''}${med != null ? ` · median ${(med / 1000).toFixed(1)}s` : ''}`);
|
|
114
|
+
}
|
|
115
|
+
const bits = [];
|
|
116
|
+
if (s.decisions_captured) bits.push(`${s.decisions_captured} decision${s.decisions_captured === 1 ? '' : 's'} captured`);
|
|
117
|
+
if (s.next_completed) bits.push(`${s.next_completed} next-action${s.next_completed === 1 ? '' : 's'} completed`);
|
|
118
|
+
if (s.next_parked) bits.push(`${s.next_parked} parked`);
|
|
119
|
+
if (s.goals_completed) bits.push(`${s.goals_completed} goal${s.goals_completed === 1 ? '' : 's'} retired`);
|
|
120
|
+
if (bits.length) out.push(bits.join(' · '));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
+
import { readSafeFile, writeSafeFile } from '../security/files.js';
|
|
9
|
+
import { withSessionLock } from '../session/lock.js';
|
|
10
|
+
|
|
11
|
+
const serverPath = fileURLToPath(new URL('../mcp.js', import.meta.url));
|
|
12
|
+
const clients = {
|
|
13
|
+
claude: { marker: '.claude', config: '.mcp.json' },
|
|
14
|
+
codex: { marker: '.codex', config: '.codex/config.toml' },
|
|
15
|
+
cursor: { marker: '.cursor', config: '.cursor/mcp.json' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function verifyServer(project) {
|
|
19
|
+
const transport = new StdioClientTransport({
|
|
20
|
+
command: process.execPath,
|
|
21
|
+
args: [serverPath],
|
|
22
|
+
env: { ...process.env, MEMOIR_PROJECT_ROOT: project, DO_NOT_TRACK: '1' },
|
|
23
|
+
stderr: 'pipe',
|
|
24
|
+
});
|
|
25
|
+
const client = new Client({ name: 'memoir-setup-check', version: '1.0.0' });
|
|
26
|
+
try {
|
|
27
|
+
await client.connect(transport, { timeout: 10000 });
|
|
28
|
+
const { tools } = await client.listTools();
|
|
29
|
+
for (const name of ['memoir_remember', 'memoir_recall', 'memoir_session']) {
|
|
30
|
+
if (!tools.some(t => t.name === name)) throw new Error('Server is missing required tool: ' + name);
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
} finally { await client.close(); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Project configuration supplies an explicit scope even for clients that
|
|
37
|
+
// launch all stdio servers with a home-directory working directory.
|
|
38
|
+
export async function setupIntegrations({ project = process.cwd(), tool = 'auto', check = true } = {}) {
|
|
39
|
+
project = await fs.realpath(path.resolve(project));
|
|
40
|
+
const selected = tool === 'all' ? Object.keys(clients) : tool === 'auto'
|
|
41
|
+
? Object.keys(clients).filter(name => fs.existsSync(path.join(os.homedir(), clients[name].marker)) || fs.existsSync(path.join(project, clients[name].config)))
|
|
42
|
+
: tool.split(',').map(s => s.trim());
|
|
43
|
+
if (selected.some(name => !clients[name])) throw new Error('Supported clients: claude, codex, cursor, all, auto');
|
|
44
|
+
if (check && selected.length) await verifyServer(project);
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const name of selected) {
|
|
47
|
+
const relative = clients[name].config;
|
|
48
|
+
const entry = { command: process.execPath, args: [serverPath], env: { MEMOIR_PROJECT_ROOT: project } };
|
|
49
|
+
await withSessionLock(path.join(project, '.memoir-setup.lock'), async () => {
|
|
50
|
+
let original = '';
|
|
51
|
+
try { original = (await readSafeFile(project, relative)).toString('utf8'); }
|
|
52
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
53
|
+
const toml = name === 'codex';
|
|
54
|
+
const parsed = original.trim() ? (toml ? parseToml(original) : JSON.parse(original)) : {};
|
|
55
|
+
const key = toml ? 'mcp_servers' : 'mcpServers';
|
|
56
|
+
const existing = parsed[key]?.memoir;
|
|
57
|
+
if (existing) {
|
|
58
|
+
const matches = existing.command === entry.command && JSON.stringify(existing.args) === JSON.stringify(entry.args) && existing.env?.MEMOIR_PROJECT_ROOT === project;
|
|
59
|
+
results.push({ tool: name, path: path.join(project, relative), status: matches ? 'ready' : 'existing-configuration', verified: matches && check });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let updated;
|
|
63
|
+
if (toml) {
|
|
64
|
+
// Preserve comments and unrelated formatting. Reject configurations
|
|
65
|
+
// whose inline table cannot be extended rather than rewriting them.
|
|
66
|
+
updated = original.trimEnd() + '\n\n' + stringifyToml({ mcp_servers: { memoir: entry } });
|
|
67
|
+
parseToml(updated);
|
|
68
|
+
} else {
|
|
69
|
+
if (parsed[key] != null && (typeof parsed[key] !== 'object' || Array.isArray(parsed[key]))) throw new Error('Invalid MCP server configuration');
|
|
70
|
+
parsed[key] = { ...(parsed[key] || {}), memoir: entry };
|
|
71
|
+
updated = JSON.stringify(parsed, null, 2) + '\n';
|
|
72
|
+
}
|
|
73
|
+
if (original) await writeSafeFile(project, relative + '.memoir-backup', original);
|
|
74
|
+
await writeSafeFile(project, relative, updated);
|
|
75
|
+
results.push({ tool: name, path: path.join(project, relative), status: 'configured', verified: check });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function setupCommand(options = {}) {
|
|
82
|
+
const results = await setupIntegrations(options);
|
|
83
|
+
if (!results.length) console.log('No supported clients detected. Use memoir setup --tool claude,codex,cursor to select them.');
|
|
84
|
+
for (const result of results) console.log(result.tool + ': ' + result.status + ' — ' + result.path);
|
|
85
|
+
if (results.some(r => r.status === 'existing-configuration')) console.log('Existing memoir entries were preserved. Review their command and project scope before using them.');
|
|
86
|
+
if (results.length) console.log('The Memoir server passed its startup check. Restart the client and approve/trust its project MCP configuration when prompted.');
|
|
87
|
+
return results;
|
|
88
|
+
}
|