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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# memoir
|
|
4
4
|
|
|
5
|
-
**Sync AI memory across every
|
|
5
|
+
**Sync AI memory across every tool and every machine — end-to-end encrypted. Free.**
|
|
6
6
|
|
|
7
7
|
[](https://npmjs.org/package/memoir-cli)
|
|
8
8
|
[](https://npmjs.org/package/memoir-cli)
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
npx memoir-cli
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
One command. No install, no config, no API keys.
|
|
18
|
+
One command. No install, no config, no API keys. Claude Code on your Mac, Cursor on your laptop, Copilot at the office — **one memory follows you** across every tool and every machine, encrypted with a key only you hold. memoir's servers literally can't read it.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
## What it does
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Your coding tools are starting to remember you — Claude Code, Cursor, and Copilot all ship built-in memory now. But that memory is **trapped: one tool, one machine, stored in plaintext.** Switch from Cursor to Claude Code, or open a different laptop, and your AI is a stranger again.
|
|
25
|
+
|
|
26
|
+
memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — encrypted client-side, so even memoir's servers can't read it.** Your AI searches, saves, and recalls context automatically, everywhere you work.
|
|
25
27
|
|
|
26
28
|
```
|
|
27
29
|
you: how does auth work in this project?
|
|
@@ -36,6 +38,20 @@ claude: Based on your previous sessions: this project uses JWT auth
|
|
|
36
38
|
|
|
37
39
|
No re-explaining. memoir remembered.
|
|
38
40
|
|
|
41
|
+
## How it's different
|
|
42
|
+
|
|
43
|
+
Native memory and the other memory tools each give you *part* of this. memoir is the only one that gives you all of it:
|
|
44
|
+
|
|
45
|
+
| | Cross-tool | Cross-machine sync | Zero-knowledge encrypted |
|
|
46
|
+
|---|:---:|:---:|:---:|
|
|
47
|
+
| **memoir** | ✅ | ✅ **free** | ✅ |
|
|
48
|
+
| Claude Code / Cursor native | ❌ one tool | ❌ one machine | ❌ |
|
|
49
|
+
| claude-mem | ✅ | ❌ local only | ❌ |
|
|
50
|
+
| basic-memory | ✅ | 💲 paid cloud | ❌ |
|
|
51
|
+
| mem0 / OpenMemory | ✅ | 💲 paid cloud | ❌ |
|
|
52
|
+
|
|
53
|
+
Native memory is locked to one tool on one machine. The others keep your memory in plaintext, or put cross-machine sync behind a paywall. memoir is the only one that does all three — every tool, every machine, encrypted under a key only you hold — for free. <sub>(Based on public docs, June 2026.)</sub>
|
|
54
|
+
|
|
39
55
|
## Quick start
|
|
40
56
|
|
|
41
57
|
```bash
|
|
@@ -67,7 +83,7 @@ Your AI gets 14 memory tools:
|
|
|
67
83
|
|
|
68
84
|
Your AI forgets everything between sessions. You re-explain your codebase, your conventions, your decisions — every time.
|
|
69
85
|
|
|
70
|
-
memoir fixes
|
|
86
|
+
memoir fixes that. Tell Claude something once and Cursor knows it too — your memory syncs between tools, backs up to the cloud, and restores on any machine. When it piles up, `memoir consolidate` cleans house: finds duplicates, flags stale context, and can use AI to merge and prune.
|
|
71
87
|
|
|
72
88
|
**11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev.
|
|
73
89
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"mcpName": "io.github.camgitt/memoir",
|
|
5
|
-
"description": "
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -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/commands/activate.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import inquirer from 'inquirer';
|
|
6
|
+
import { detectAvailableTargets } from '../session/inject.js';
|
|
6
7
|
|
|
7
8
|
// The instruction files each AI tool reads, in priority order
|
|
8
9
|
const INSTRUCTION_FILES = [
|
|
@@ -65,6 +66,25 @@ async function injectBlock(filePath) {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Auto-activate recall GLOBALLY: ensure the memoir instruction block exists in
|
|
71
|
+
* each installed tool's user-global config (e.g. ~/.claude/CLAUDE.md), so the AI
|
|
72
|
+
* is told to use memoir_recall/remember in EVERY project — no per-project step.
|
|
73
|
+
* Idempotent and additive (never clobbers existing content). Opt out by setting
|
|
74
|
+
* MEMOIR_NO_AUTO_ACTIVATE. Called from the SessionStart hook (auto-refresh).
|
|
75
|
+
*/
|
|
76
|
+
export async function ensureRecallInstruction() {
|
|
77
|
+
if (process.env.MEMOIR_NO_AUTO_ACTIVATE) return { skipped: true, added: 0 };
|
|
78
|
+
let added = 0;
|
|
79
|
+
for (const target of Object.values(detectAvailableTargets())) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await injectBlock(target);
|
|
82
|
+
if (res === 'appended' || res === 'created') added++;
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
return { added };
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
/**
|
|
69
89
|
* Remove memoir block from a file
|
|
70
90
|
*/
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
import { readSession } from '../session/state.js';
|
|
10
10
|
import { renderSession } from '../session/render.js';
|
|
11
11
|
import { injectInto, detectAvailableTargets } from '../session/inject.js';
|
|
12
|
+
import { ensureRecallInstruction } from './activate.js';
|
|
13
|
+
import { tidyIndex } from './tidy.js';
|
|
14
|
+
import { resolveHomeMemoryDir } from '../context/capture.js';
|
|
12
15
|
|
|
13
16
|
export async function autoRefreshCommand(options = {}) {
|
|
14
17
|
const verbose = !!options.verbose;
|
|
@@ -24,6 +27,30 @@ export async function autoRefreshCommand(options = {}) {
|
|
|
24
27
|
if (verbose) console.error(`memoir auto-refresh: ${tool} failed: ${err.message}`);
|
|
25
28
|
}
|
|
26
29
|
}
|
|
30
|
+
// Ensure recall is globally active (idempotent) so the AI uses memoir in
|
|
31
|
+
// every project without a manual `memoir activate`.
|
|
32
|
+
try {
|
|
33
|
+
const r = await ensureRecallInstruction();
|
|
34
|
+
if (verbose && r.added) console.log(`memoir auto-refresh: enabled recall in ${r.added} global config(s)`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (verbose) console.error(`memoir auto-refresh: ensureRecallInstruction failed: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
// Lean-memory: keep the loaded index under budget so the AI loads ALL of it
|
|
39
|
+
// and wastes no context on bloat. Over-budget-only, archive-not-delete,
|
|
40
|
+
// opt out with MEMOIR_NO_AUTO_TIDY.
|
|
41
|
+
if (!process.env.MEMOIR_NO_AUTO_TIDY) {
|
|
42
|
+
try {
|
|
43
|
+
const dir = resolveHomeMemoryDir();
|
|
44
|
+
if (dir) {
|
|
45
|
+
const t = await tidyIndex(dir, { stamp: 'auto' });
|
|
46
|
+
if (verbose && t.archived?.length) {
|
|
47
|
+
console.log(`memoir auto-refresh: tidied index → archived ${t.archived.length} section(s), now ${t.newLineCount} lines`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (verbose) console.error(`memoir auto-refresh: tidy failed: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
27
54
|
} catch (err) {
|
|
28
55
|
if (verbose) console.error(`memoir auto-refresh: ${err.message}`);
|
|
29
56
|
// Never fail the hook — session start must proceed.
|
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,23 +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
|
-
const words = text.split(/\s+/).length;
|
|
164
|
-
if (words < 3) return false; // less than 3 words isn't a decision
|
|
165
|
-
return true;
|
|
166
|
-
};
|
|
167
|
-
for (const d of parsed.decisions.slice(0, 10)) {
|
|
214
|
+
for (const d of qualityDecisions.slice(0, 10)) {
|
|
168
215
|
const text = String(d.value || '').trim();
|
|
169
|
-
if (!isQuality(text)) continue;
|
|
170
216
|
if (existingTexts.has(text.toLowerCase())) continue;
|
|
171
217
|
await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
|
|
172
218
|
}
|
|
@@ -225,11 +271,38 @@ export async function pushCommand(options = {}) {
|
|
|
225
271
|
// Workspace scan is best-effort
|
|
226
272
|
}
|
|
227
273
|
|
|
228
|
-
// 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.
|
|
229
292
|
let sessionIncluded = false;
|
|
230
293
|
try {
|
|
231
294
|
if (await fs.pathExists(sessionPaths.session)) {
|
|
232
|
-
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));
|
|
233
306
|
sessionIncluded = true;
|
|
234
307
|
}
|
|
235
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);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Lean-memory: keep the loaded memory index (MEMORY.md) under a line budget so
|
|
2
|
+
// the AI loads ALL of it (Claude Code reads only ~200 lines) and wastes no
|
|
3
|
+
// context on bloat. When over budget, the fattest *inline* sections are moved
|
|
4
|
+
// into a dated archive file and replaced with one-line pointers.
|
|
5
|
+
//
|
|
6
|
+
// Guarantees: archive-not-delete (nothing lost), never touches the critical
|
|
7
|
+
// behavior-rules section or the preamble, idempotent, dry-run capable,
|
|
8
|
+
// code-fence aware, content-deduped, atomic writes, graceful on errors.
|
|
9
|
+
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { appendEvent } from '../events/log.js';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
|
|
15
|
+
|
|
16
|
+
// Split into ## sections — but a "## " INSIDE a fenced code block (``` or ~~~)
|
|
17
|
+
// is content, not a header, so we never split there (would orphan content +
|
|
18
|
+
// leave an unclosed fence = invalid markdown + data loss).
|
|
19
|
+
function splitSections(text) {
|
|
20
|
+
const sections = [];
|
|
21
|
+
let cur = { header: '(preamble)', lines: [] };
|
|
22
|
+
let fence = null; // active fence marker while inside a code block
|
|
23
|
+
for (const line of text.split('\n')) {
|
|
24
|
+
const t = line.trimStart();
|
|
25
|
+
const m = t.match(/^(```|~~~)/);
|
|
26
|
+
if (m) {
|
|
27
|
+
if (!fence) fence = m[1];
|
|
28
|
+
else if (t.startsWith(fence)) fence = null;
|
|
29
|
+
}
|
|
30
|
+
if (!fence && /^##\s/.test(line)) {
|
|
31
|
+
sections.push(cur);
|
|
32
|
+
cur = { header: line.replace(/^##\s+/, '').trim(), lines: [line] };
|
|
33
|
+
} else {
|
|
34
|
+
cur.lines.push(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
sections.push(cur);
|
|
38
|
+
return sections;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A line is a lightweight pointer (not inline content to archive) if it's a
|
|
42
|
+
// clean `- [text](file)` OR any link to one of our archive files.
|
|
43
|
+
function isPointer(t) {
|
|
44
|
+
if (/^- \[[^\]]+\]\([^)]+\)/.test(t)) return true;
|
|
45
|
+
if (/\[[^\]]*\]\(memory_index_archive_[^)]*\)/.test(t)) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inlineWeight(section) {
|
|
50
|
+
return section.lines.filter(l => {
|
|
51
|
+
const t = l.trim();
|
|
52
|
+
if (!t) return false;
|
|
53
|
+
if (/^#{2,3}\s/.test(t)) return false;
|
|
54
|
+
if (isPointer(t)) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}).length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
|
|
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
|
+
|
|
70
|
+
async function atomicWrite(filePath, content) {
|
|
71
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
72
|
+
await fs.writeFile(tmp, content);
|
|
73
|
+
await fs.move(tmp, filePath, { overwrite: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tidy MEMORY.md down under `budgetLines`.
|
|
78
|
+
* @returns { overBudget, lineCount, newLineCount?, budgetLines, archived[], archiveFile?, dryRun? } | { ok:false, reason }
|
|
79
|
+
*/
|
|
80
|
+
export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRun = false, stamp = 'archive' } = {}) {
|
|
81
|
+
const mdPath = path.join(memoryDir, 'MEMORY.md');
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
if (!await fs.pathExists(mdPath)) return { ok: false, reason: 'no MEMORY.md' };
|
|
85
|
+
text = await fs.readFile(mdPath, 'utf8');
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { ok: false, reason: `read failed: ${err.code || err.message}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lineCount = text.split('\n').length;
|
|
91
|
+
if (lineCount <= budgetLines) return { overBudget: false, lineCount, budgetLines, archived: [] };
|
|
92
|
+
|
|
93
|
+
const sections = splitSections(text);
|
|
94
|
+
const archiveFile = `memory_index_archive_${stamp}.md`;
|
|
95
|
+
const archivePath = path.join(memoryDir, archiveFile);
|
|
96
|
+
|
|
97
|
+
// Read the prior archive ONCE so we can content-dedup (no re-append bloat).
|
|
98
|
+
let priorArchive = '';
|
|
99
|
+
try { if (await fs.pathExists(archivePath)) priorArchive = await fs.readFile(archivePath, 'utf8'); } catch {}
|
|
100
|
+
|
|
101
|
+
// Fattest inline sections first; skip empty headers (would make `- []()`) and
|
|
102
|
+
// protected sections.
|
|
103
|
+
const candidates = sections
|
|
104
|
+
.map((s, i) => ({ i, s, weight: inlineWeight(s) }))
|
|
105
|
+
.filter(c => c.weight >= 6 && c.s.header.trim().length > 0 && !PROTECTED(c.s.header))
|
|
106
|
+
.sort((a, b) => b.weight - a.weight);
|
|
107
|
+
|
|
108
|
+
const removeIdx = new Map();
|
|
109
|
+
const archived = [];
|
|
110
|
+
let toAppend = '';
|
|
111
|
+
let projected = lineCount;
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
if (projected <= budgetLines) break;
|
|
114
|
+
const body = c.s.lines.join('\n');
|
|
115
|
+
const key = body.trim();
|
|
116
|
+
// Only append content not already archived — dedup prevents bloat; the
|
|
117
|
+
// section is still safely in the archive so removing it from MEMORY.md is
|
|
118
|
+
// never a loss.
|
|
119
|
+
if (key && !priorArchive.includes(key) && !toAppend.includes(key)) {
|
|
120
|
+
toAppend += body + '\n\n';
|
|
121
|
+
}
|
|
122
|
+
removeIdx.set(c.i, `- [${c.s.header}](${archiveFile}) — moved out of the index ${stamp} (full detail in file)`);
|
|
123
|
+
archived.push({ section: c.s.header, lines: c.s.lines.length });
|
|
124
|
+
projected -= (c.s.lines.length - 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!archived.length) return { overBudget: true, lineCount, budgetLines, archived: [], note: 'over budget but no fat inline sections found' };
|
|
128
|
+
if (dryRun) return { overBudget: true, lineCount, projectedLines: projected, budgetLines, wouldArchive: archived, dryRun: true };
|
|
129
|
+
|
|
130
|
+
const out = [];
|
|
131
|
+
for (let i = 0; i < sections.length; i++) {
|
|
132
|
+
if (removeIdx.has(i)) out.push(removeIdx.get(i));
|
|
133
|
+
else out.push(...sections[i].lines);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!out.some(l => l.includes('memoir:schemaVersion'))) {
|
|
137
|
+
out.push(MEMORY_SCHEMA_MARKER);
|
|
138
|
+
}
|
|
139
|
+
|
|
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`;
|
|
141
|
+
const base = priorArchive || fm;
|
|
142
|
+
if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
|
|
143
|
+
await atomicWrite(mdPath, out.join('\n'));
|
|
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
|
+
|
|
151
|
+
return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile };
|
|
152
|
+
}
|
package/src/commands/why.js
CHANGED
|
@@ -8,16 +8,30 @@ import { readSession } from '../session/state.js';
|
|
|
8
8
|
|
|
9
9
|
function searchDecisions(decisions, query) {
|
|
10
10
|
if (!query) return decisions;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// Tokenize the query and match decisions containing any term, ranked by how
|
|
12
|
+
// many terms hit (recency breaks ties). A single whole-phrase substring match
|
|
13
|
+
// silently missed multi-word queries like "memoir positioning" even when every
|
|
14
|
+
// word was present — which is exactly how the MCP memoir_why tool queries.
|
|
15
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
16
|
+
if (!terms.length) return decisions;
|
|
17
|
+
return decisions
|
|
18
|
+
.map(d => {
|
|
19
|
+
const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase();
|
|
20
|
+
const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
|
|
21
|
+
return { d, score };
|
|
22
|
+
})
|
|
23
|
+
.filter(x => x.score > 0)
|
|
24
|
+
.sort((a, b) => b.score - a.score || String(b.d.date || '').localeCompare(String(a.d.date || '')))
|
|
25
|
+
.map(x => x.d);
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export async function whyCommand(query) {
|
|
19
29
|
const state = await readSession();
|
|
20
|
-
|
|
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);
|
|
21
35
|
const matches = searchDecisions(decisions, query);
|
|
22
36
|
|
|
23
37
|
if (matches.length === 0) {
|
|
@@ -44,7 +58,10 @@ export async function whyCommand(query) {
|
|
|
44
58
|
console.log('\n' + lines.join('\n'));
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
// 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.
|
|
48
64
|
export function findDecisions(state, query) {
|
|
49
|
-
|
|
65
|
+
const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
|
|
66
|
+
return searchDecisions(decisions, query);
|
|
50
67
|
}
|