memoir-cli 3.8.1 → 3.9.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 +2 -2
- package/src/adapters/index.js +10 -12
- package/src/commands/activate.js +20 -0
- package/src/commands/auto-refresh.js +27 -0
- package/src/commands/push.js +2 -0
- package/src/commands/tidy.js +132 -0
- package/src/commands/why.js +15 -5
- package/src/context/capture.js +37 -29
- package/src/utils/platform.js +47 -0
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.9.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": {
|
package/src/adapters/index.js
CHANGED
|
@@ -4,12 +4,18 @@ 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';
|
|
7
8
|
|
|
8
9
|
const home = os.homedir();
|
|
9
10
|
|
|
10
11
|
const isWin = process.platform === 'win32';
|
|
11
12
|
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
12
13
|
|
|
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
|
+
|
|
13
19
|
export const adapters = [
|
|
14
20
|
{
|
|
15
21
|
name: 'Gemini CLI',
|
|
@@ -78,13 +84,9 @@ export const adapters = [
|
|
|
78
84
|
{
|
|
79
85
|
name: 'Cursor',
|
|
80
86
|
icon: '⚡',
|
|
81
|
-
source:
|
|
82
|
-
? path.join(appData, 'Cursor', 'User')
|
|
83
|
-
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User'),
|
|
87
|
+
source: cursorUserDir,
|
|
84
88
|
filter: (src) => {
|
|
85
|
-
const cursorDir =
|
|
86
|
-
? path.join(appData, 'Cursor', 'User')
|
|
87
|
-
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
|
|
89
|
+
const cursorDir = cursorUserDir;
|
|
88
90
|
const rel = path.relative(cursorDir, src);
|
|
89
91
|
if (src === cursorDir) return true;
|
|
90
92
|
const basename = path.basename(src);
|
|
@@ -117,13 +119,9 @@ export const adapters = [
|
|
|
117
119
|
{
|
|
118
120
|
name: 'Windsurf',
|
|
119
121
|
icon: '🏄',
|
|
120
|
-
source:
|
|
121
|
-
? path.join(appData, 'Windsurf', 'User')
|
|
122
|
-
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
|
|
122
|
+
source: windsurfUserDir,
|
|
123
123
|
filter: (src) => {
|
|
124
|
-
const windsurfDir =
|
|
125
|
-
? path.join(appData, 'Windsurf', 'User')
|
|
126
|
-
: path.join(home, 'Library', 'Application Support', 'Windsurf', 'User');
|
|
124
|
+
const windsurfDir = windsurfUserDir;
|
|
127
125
|
const rel = path.relative(windsurfDir, src);
|
|
128
126
|
if (src === windsurfDir) return true;
|
|
129
127
|
const basename = path.basename(src);
|
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/push.js
CHANGED
|
@@ -160,6 +160,8 @@ export async function pushCommand(options = {}) {
|
|
|
160
160
|
if (/\|/.test(text)) return false; // markdown table fragment
|
|
161
161
|
if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
|
|
162
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
|
|
163
165
|
const words = text.split(/\s+/).length;
|
|
164
166
|
if (words < 3) return false; // less than 3 words isn't a decision
|
|
165
167
|
return true;
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
13
|
+
export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
|
|
14
|
+
|
|
15
|
+
// Split into ## sections — but a "## " INSIDE a fenced code block (``` or ~~~)
|
|
16
|
+
// is content, not a header, so we never split there (would orphan content +
|
|
17
|
+
// leave an unclosed fence = invalid markdown + data loss).
|
|
18
|
+
function splitSections(text) {
|
|
19
|
+
const sections = [];
|
|
20
|
+
let cur = { header: '(preamble)', lines: [] };
|
|
21
|
+
let fence = null; // active fence marker while inside a code block
|
|
22
|
+
for (const line of text.split('\n')) {
|
|
23
|
+
const t = line.trimStart();
|
|
24
|
+
const m = t.match(/^(```|~~~)/);
|
|
25
|
+
if (m) {
|
|
26
|
+
if (!fence) fence = m[1];
|
|
27
|
+
else if (t.startsWith(fence)) fence = null;
|
|
28
|
+
}
|
|
29
|
+
if (!fence && /^##\s/.test(line)) {
|
|
30
|
+
sections.push(cur);
|
|
31
|
+
cur = { header: line.replace(/^##\s+/, '').trim(), lines: [line] };
|
|
32
|
+
} else {
|
|
33
|
+
cur.lines.push(line);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
sections.push(cur);
|
|
37
|
+
return sections;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A line is a lightweight pointer (not inline content to archive) if it's a
|
|
41
|
+
// clean `- [text](file)` OR any link to one of our archive files.
|
|
42
|
+
function isPointer(t) {
|
|
43
|
+
if (/^- \[[^\]]+\]\([^)]+\)/.test(t)) return true;
|
|
44
|
+
if (/\[[^\]]*\]\(memory_index_archive_[^)]*\)/.test(t)) return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function inlineWeight(section) {
|
|
49
|
+
return section.lines.filter(l => {
|
|
50
|
+
const t = l.trim();
|
|
51
|
+
if (!t) return false;
|
|
52
|
+
if (/^#{2,3}\s/.test(t)) return false;
|
|
53
|
+
if (isPointer(t)) return false;
|
|
54
|
+
return true;
|
|
55
|
+
}).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
|
|
59
|
+
|
|
60
|
+
async function atomicWrite(filePath, content) {
|
|
61
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
62
|
+
await fs.writeFile(tmp, content);
|
|
63
|
+
await fs.move(tmp, filePath, { overwrite: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tidy MEMORY.md down under `budgetLines`.
|
|
68
|
+
* @returns { overBudget, lineCount, newLineCount?, budgetLines, archived[], archiveFile?, dryRun? } | { ok:false, reason }
|
|
69
|
+
*/
|
|
70
|
+
export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRun = false, stamp = 'archive' } = {}) {
|
|
71
|
+
const mdPath = path.join(memoryDir, 'MEMORY.md');
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
if (!await fs.pathExists(mdPath)) return { ok: false, reason: 'no MEMORY.md' };
|
|
75
|
+
text = await fs.readFile(mdPath, 'utf8');
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { ok: false, reason: `read failed: ${err.code || err.message}` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lineCount = text.split('\n').length;
|
|
81
|
+
if (lineCount <= budgetLines) return { overBudget: false, lineCount, budgetLines, archived: [] };
|
|
82
|
+
|
|
83
|
+
const sections = splitSections(text);
|
|
84
|
+
const archiveFile = `memory_index_archive_${stamp}.md`;
|
|
85
|
+
const archivePath = path.join(memoryDir, archiveFile);
|
|
86
|
+
|
|
87
|
+
// Read the prior archive ONCE so we can content-dedup (no re-append bloat).
|
|
88
|
+
let priorArchive = '';
|
|
89
|
+
try { if (await fs.pathExists(archivePath)) priorArchive = await fs.readFile(archivePath, 'utf8'); } catch {}
|
|
90
|
+
|
|
91
|
+
// Fattest inline sections first; skip empty headers (would make `- []()`) and
|
|
92
|
+
// protected sections.
|
|
93
|
+
const candidates = sections
|
|
94
|
+
.map((s, i) => ({ i, s, weight: inlineWeight(s) }))
|
|
95
|
+
.filter(c => c.weight >= 6 && c.s.header.trim().length > 0 && !PROTECTED(c.s.header))
|
|
96
|
+
.sort((a, b) => b.weight - a.weight);
|
|
97
|
+
|
|
98
|
+
const removeIdx = new Map();
|
|
99
|
+
const archived = [];
|
|
100
|
+
let toAppend = '';
|
|
101
|
+
let projected = lineCount;
|
|
102
|
+
for (const c of candidates) {
|
|
103
|
+
if (projected <= budgetLines) break;
|
|
104
|
+
const body = c.s.lines.join('\n');
|
|
105
|
+
const key = body.trim();
|
|
106
|
+
// Only append content not already archived — dedup prevents bloat; the
|
|
107
|
+
// section is still safely in the archive so removing it from MEMORY.md is
|
|
108
|
+
// never a loss.
|
|
109
|
+
if (key && !priorArchive.includes(key) && !toAppend.includes(key)) {
|
|
110
|
+
toAppend += body + '\n\n';
|
|
111
|
+
}
|
|
112
|
+
removeIdx.set(c.i, `- [${c.s.header}](${archiveFile}) — moved out of the index ${stamp} (full detail in file)`);
|
|
113
|
+
archived.push({ section: c.s.header, lines: c.s.lines.length });
|
|
114
|
+
projected -= (c.s.lines.length - 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!archived.length) return { overBudget: true, lineCount, budgetLines, archived: [], note: 'over budget but no fat inline sections found' };
|
|
118
|
+
if (dryRun) return { overBudget: true, lineCount, projectedLines: projected, budgetLines, wouldArchive: archived, dryRun: true };
|
|
119
|
+
|
|
120
|
+
const out = [];
|
|
121
|
+
for (let i = 0; i < sections.length; i++) {
|
|
122
|
+
if (removeIdx.has(i)) out.push(removeIdx.get(i));
|
|
123
|
+
else out.push(...sections[i].lines);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
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
|
+
const base = priorArchive || fm;
|
|
128
|
+
if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
|
|
129
|
+
await atomicWrite(mdPath, out.join('\n'));
|
|
130
|
+
|
|
131
|
+
return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile };
|
|
132
|
+
}
|
package/src/commands/why.js
CHANGED
|
@@ -8,11 +8,21 @@ 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) {
|
package/src/context/capture.js
CHANGED
|
@@ -151,6 +151,16 @@ function parseLines(lines) {
|
|
|
151
151
|
return result;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Reject conversational fragments that loose regexes sometimes capture as
|
|
155
|
+
// "decisions" — questions, and clauses starting with a pronoun/filler word
|
|
156
|
+
// ("we pick this back up Monday", "it up at...", "some lenders may...").
|
|
157
|
+
function looksLikeFragment(v) {
|
|
158
|
+
if (!v) return true;
|
|
159
|
+
if (/\?/.test(v)) return true;
|
|
160
|
+
if (/^(it|this|that|these|those|we|i|they|you|he|she|some|there|here|just|back|now|also)\b/i.test(v)) return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
/**
|
|
155
165
|
* Extract durable decisions from session conversation.
|
|
156
166
|
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
@@ -168,16 +178,19 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
168
178
|
// Tech choices
|
|
169
179
|
{ regex: /(?:let'?s|we(?:'ll| will| should)?|going to|decided to)\s+use\s+([A-Z][a-zA-Z0-9_./-]+)\s+(?:for|instead|as|to)/gi, type: 'tech' },
|
|
170
180
|
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
171
|
-
// Architecture / design
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
// Architecture / design — require an explicit decision verb and a capitalized
|
|
182
|
+
// target. Bare "pick/choose" caught conversational fragments as decisions.
|
|
183
|
+
{ regex: /(?:decided|settled|going|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
|
|
184
|
+
// Stack choices — require a capitalized, tech-looking value, not a prose
|
|
185
|
+
// fragment ("backend is just throwing it away" used to leak through).
|
|
186
|
+
{ regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' },
|
|
175
187
|
];
|
|
176
188
|
|
|
177
189
|
for (const { regex, type } of patterns) {
|
|
178
190
|
let match;
|
|
179
191
|
while ((match = regex.exec(allText)) !== null) {
|
|
180
192
|
const value = match[1].trim().replace(/["']+$/, '');
|
|
193
|
+
if (looksLikeFragment(value)) continue;
|
|
181
194
|
if (value.length > 2 && value.length < 80) {
|
|
182
195
|
// Avoid duplicates
|
|
183
196
|
const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
|
|
@@ -200,6 +213,24 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
200
213
|
return decisions.slice(0, 20); // Cap at 20 decisions per session
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Resolve the HOME-level memory dir (~/.claude/projects/<home-key>/memory) —
|
|
218
|
+
* the one that matches the user's home path encoding, not a sub-project.
|
|
219
|
+
* Returns null if none exists. Shared by persistDecisions + lean-memory tidy.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveHomeMemoryDir(claudeSource) {
|
|
222
|
+
const claudeDir = claudeSource || path.join(home, '.claude');
|
|
223
|
+
const projectsDir = path.join(claudeDir, 'projects');
|
|
224
|
+
// Canonical home-key path ONLY. We deliberately do NOT fall back to "shortest
|
|
225
|
+
// dir that has a memory/ subfolder" — on a shared machine that could silently
|
|
226
|
+
// target a different project's (or teammate's) memory. Callers create the dir
|
|
227
|
+
// if needed; tidy safely no-ops when MEMORY.md is absent.
|
|
228
|
+
const homeKey = process.platform === 'win32'
|
|
229
|
+
? home.replace(/\\/g, '-').replace(/:/g, '-')
|
|
230
|
+
: '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
231
|
+
return path.join(projectsDir, homeKey, 'memory');
|
|
232
|
+
}
|
|
233
|
+
|
|
203
234
|
/**
|
|
204
235
|
* Write extracted decisions to Claude's persistent memory.
|
|
205
236
|
* This ensures decisions survive across sessions and machines.
|
|
@@ -207,31 +238,8 @@ function extractDecisions(userMessages, assistantTexts) {
|
|
|
207
238
|
export function persistDecisions(decisions, claudeSource) {
|
|
208
239
|
if (!decisions || decisions.length === 0) return 0;
|
|
209
240
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
if (!fs.existsSync(projectsDir)) return 0;
|
|
213
|
-
|
|
214
|
-
// Find the HOME-level memory dir (not project-specific)
|
|
215
|
-
// This is the dir that matches the user's home path encoding
|
|
216
|
-
let homeKey;
|
|
217
|
-
if (process.platform === 'win32') {
|
|
218
|
-
homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
|
|
219
|
-
} else {
|
|
220
|
-
homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Try exact match first, then detect from existing dirs
|
|
224
|
-
let memDir = path.join(projectsDir, homeKey, 'memory');
|
|
225
|
-
if (!fs.existsSync(memDir)) {
|
|
226
|
-
// Fallback: find dirs with memory/ subfolder, pick shortest name (likely home-level)
|
|
227
|
-
const entries = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
228
|
-
.filter(e => e.isDirectory() && fs.existsSync(path.join(projectsDir, e.name, 'memory')));
|
|
229
|
-
if (entries.length === 0) return 0;
|
|
230
|
-
// Shortest dir name is most likely the home key (not a sub-project)
|
|
231
|
-
const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0];
|
|
232
|
-
memDir = path.join(projectsDir, homeEntry.name, 'memory');
|
|
233
|
-
}
|
|
234
|
-
|
|
241
|
+
const memDir = resolveHomeMemoryDir(claudeSource);
|
|
242
|
+
if (!memDir) return 0;
|
|
235
243
|
fs.mkdirSync(memDir, { recursive: true });
|
|
236
244
|
const decisionsFile = path.join(memDir, 'session-decisions.md');
|
|
237
245
|
const memoryMdPath = path.join(memDir, 'MEMORY.md');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single source of truth for OS-specific config locations.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: before this module, every adapter branched on a single
|
|
4
|
+
// `isWin` flag — `isWin ? <windows> : <macOS>`. On Linux, `process.platform`
|
|
5
|
+
// is `'linux'`, so those ternaries silently fell through to the *macOS* path
|
|
6
|
+
// (`~/Library/Application Support/...`), which doesn't exist on Linux. memoir
|
|
7
|
+
// would then detect zero tools, sync nothing, and the user would churn without
|
|
8
|
+
// any error — the "silent-zero-memory activation cliff."
|
|
9
|
+
//
|
|
10
|
+
// Every function is parameterized by { platform, env, home } so the three OS
|
|
11
|
+
// branches can be unit-tested from any machine, not just the target OS. Runtime
|
|
12
|
+
// callers omit the options and get the live platform.
|
|
13
|
+
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
|
|
17
|
+
const HOME = os.homedir();
|
|
18
|
+
|
|
19
|
+
// VS Code-family per-user config base: <root>/<App>/User
|
|
20
|
+
// win32 : %APPDATA%/<App>/User
|
|
21
|
+
// darwin : ~/Library/Application Support/<App>/User
|
|
22
|
+
// linux : $XDG_CONFIG_HOME (or ~/.config)/<App>/User
|
|
23
|
+
export function vscodeUserDir(appName, { platform = process.platform, env = process.env, home = HOME } = {}) {
|
|
24
|
+
if (platform === 'win32') {
|
|
25
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
26
|
+
return path.join(appData, appName, 'User');
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'darwin') {
|
|
29
|
+
return path.join(home, 'Library', 'Application Support', appName, 'User');
|
|
30
|
+
}
|
|
31
|
+
// linux + anything else POSIX-y
|
|
32
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
33
|
+
return path.join(xdg, appName, 'User');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A VS Code extension's globalStorage dir (e.g. Cline lives under the base
|
|
37
|
+
// "Code" install, not its own app dir).
|
|
38
|
+
export function vscodeGlobalStorage(extId, opts = {}) {
|
|
39
|
+
return path.join(vscodeUserDir('Code', opts), 'globalStorage', extId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// XDG-aware ~/.config base, for non-VSCode tools that already store there
|
|
43
|
+
// (zed, github-copilot). Exposed so callers don't re-hardcode ~/.config and
|
|
44
|
+
// drift from XDG_CONFIG_HOME.
|
|
45
|
+
export function xdgConfigDir({ env = process.env, home = HOME } = {}) {
|
|
46
|
+
return env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
47
|
+
}
|