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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # memoir
4
4
 
5
- **Sync AI memory across every coding tool. Zero config.**
5
+ **Sync AI memory across every tool and every machine — end-to-end encrypted. Free.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/memoir-cli.svg?style=flat-square&color=7c6ef0)](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. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and 6 more tools.
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
- memoir is an [MCP memory server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically like a Claude Code backup that works everywhere.
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 this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too. Sync AI memory between tools, back it up to the cloud, restore it on any machine. And when your memories pile up, `memoir consolidate` cleans house finds duplicates, flags stale context, and optionally uses AI to merge and prune.
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.8.1",
3
+ "version": "3.9.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
- "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferencesacross tools and machines.",
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": {
@@ -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: isWin
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 = isWin
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: isWin
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 = isWin
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);
@@ -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.
@@ -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
+ }
@@ -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
- const q = String(query).toLowerCase();
12
- return decisions.filter(d => {
13
- const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase();
14
- return haystack.includes(q);
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) {
@@ -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
- { regex: /(?:let'?s|we(?:'ll| will| should)?)\s+(?:go with|pick|choose)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'design' },
173
- // Stack choices
174
- { regex: /(?:stack|framework|database|backend|frontend)\s+(?:is|will be|should be)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'stack' },
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 claudeDir = claudeSource || path.join(home, '.claude');
211
- const projectsDir = path.join(claudeDir, 'projects');
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
+ }