memoir-cli 3.17.0 → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.17.0",
3
+ "version": "3.17.1",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
@@ -1,7 +1,13 @@
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.
1
+ // Lean-memory: keep the loaded memory index (MEMORY.md) under a budget so the
2
+ // AI loads ALL of it and wastes no context on bloat. When over budget, the
3
+ // fattest sections are moved into a dated archive file and replaced with
4
+ // one-line pointers.
5
+ //
6
+ // Two budgets, both enforced: lines (Claude Code reads only ~200) and
7
+ // characters (what the index actually costs in tokens). Characters were added
8
+ // after a real 19,450-character index sat at 84 lines — under half the line
9
+ // budget — and was therefore never tidied once, while costing ~5,000 tokens on
10
+ // every session.
5
11
  //
6
12
  // Guarantees: archive-not-delete (nothing lost), never touches the critical
7
13
  // behavior-rules section or the preamble, idempotent, dry-run capable,
@@ -13,6 +19,19 @@ import { appendEvent } from '../events/log.js';
13
19
 
14
20
  export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
15
21
 
22
+ // Lines were the wrong unit. A real index (19,450 chars in 84 lines) sat at
23
+ // less than half the line budget and was never tidied once, because the cost
24
+ // that matters is characters — those 84 lines were ~5,000 tokens, 12% of every
25
+ // session's opening prompt. Both budgets are enforced now; whichever is
26
+ // exceeded first triggers a tidy.
27
+ export const DEFAULT_BUDGET_CHARS = 12000; // ~3,000 tokens of loaded index.
28
+
29
+ // A pointer is only cheap if it is actually short. `- [Title](file.md) — hook`
30
+ // is a pointer; the same shape carrying 400 characters of summary is inline
31
+ // content wearing a pointer's costume, and treating it as weightless is what
32
+ // let the index grow unbounded while every section scored zero.
33
+ const POINTER_MAX_CHARS = 160;
34
+
16
35
  // Split into ## sections — but a "## " INSIDE a fenced code block (``` or ~~~)
17
36
  // is content, not a header, so we never split there (would orphan content +
18
37
  // leave an unclosed fence = invalid markdown + data loss).
@@ -46,6 +65,8 @@ function isPointer(t) {
46
65
  return false;
47
66
  }
48
67
 
68
+ // Lines of inline (non-pointer, non-header) content — the original metric,
69
+ // kept because the line budget still uses it.
49
70
  function inlineWeight(section) {
50
71
  return section.lines.filter(l => {
51
72
  const t = l.trim();
@@ -56,6 +77,20 @@ function inlineWeight(section) {
56
77
  }).length;
57
78
  }
58
79
 
80
+ // Characters a section costs to load. Headers are free (they stay either way).
81
+ // A short pointer is free; an over-long one is charged in full, because that is
82
+ // exactly the line the old metric scored at zero.
83
+ function charWeight(section) {
84
+ let total = 0;
85
+ for (const line of section.lines) {
86
+ const t = line.trim();
87
+ if (!t || /^#{2,3}\s/.test(t)) continue;
88
+ if (isPointer(t) && t.length <= POINTER_MAX_CHARS) continue;
89
+ total += line.length + 1; // + the newline it costs
90
+ }
91
+ return total;
92
+ }
93
+
59
94
  const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
60
95
 
61
96
  // Informational-only schema marker for MEMORY.md itself (distinct from — and
@@ -74,10 +109,10 @@ async function atomicWrite(filePath, content) {
74
109
  }
75
110
 
76
111
  /**
77
- * Tidy MEMORY.md down under `budgetLines`.
78
- * @returns { overBudget, lineCount, newLineCount?, budgetLines, archived[], archiveFile?, dryRun? } | { ok:false, reason }
112
+ * Tidy MEMORY.md down under `budgetLines` AND `budgetChars`.
113
+ * @returns { overBudget, lineCount, charCount, newLineCount?, newCharCount?, budgetLines, budgetChars, archived[], archiveFile?, dryRun? } | { ok:false, reason }
79
114
  */
80
- export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRun = false, stamp = 'archive' } = {}) {
115
+ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, budgetChars = DEFAULT_BUDGET_CHARS, dryRun = false, stamp = 'archive' } = {}) {
81
116
  const mdPath = path.join(memoryDir, 'MEMORY.md');
82
117
  let text;
83
118
  try {
@@ -88,7 +123,10 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
88
123
  }
89
124
 
90
125
  const lineCount = text.split('\n').length;
91
- if (lineCount <= budgetLines) return { overBudget: false, lineCount, budgetLines, archived: [] };
126
+ const charCount = text.length;
127
+ if (lineCount <= budgetLines && charCount <= budgetChars) {
128
+ return { overBudget: false, lineCount, charCount, budgetLines, budgetChars, archived: [] };
129
+ }
92
130
 
93
131
  const sections = splitSections(text);
94
132
  const archiveFile = `memory_index_archive_${stamp}.md`;
@@ -98,19 +136,21 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
98
136
  let priorArchive = '';
99
137
  try { if (await fs.pathExists(archivePath)) priorArchive = await fs.readFile(archivePath, 'utf8'); } catch {}
100
138
 
101
- // Fattest inline sections first; skip empty headers (would make `- []()`) and
102
- // protected sections.
139
+ // Skip empty headers (would make `- []()`) and protected sections.
140
+ // A section is worth archiving if it is fat in EITHER unit. Ranked by
141
+ // characters, since that is what the loaded index actually costs.
103
142
  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);
143
+ .map((s, i) => ({ i, s, weight: inlineWeight(s), chars: charWeight(s) }))
144
+ .filter(c => (c.weight >= 6 || c.chars >= 600) && c.s.header.trim().length > 0 && !PROTECTED(c.s.header))
145
+ .sort((a, b) => b.chars - a.chars || b.weight - a.weight);
107
146
 
108
147
  const removeIdx = new Map();
109
148
  const archived = [];
110
149
  let toAppend = '';
111
150
  let projected = lineCount;
151
+ let projectedChars = charCount;
112
152
  for (const c of candidates) {
113
- if (projected <= budgetLines) break;
153
+ if (projected <= budgetLines && projectedChars <= budgetChars) break;
114
154
  const body = c.s.lines.join('\n');
115
155
  const key = body.trim();
116
156
  // Only append content not already archived — dedup prevents bloat; the
@@ -119,13 +159,15 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
119
159
  if (key && !priorArchive.includes(key) && !toAppend.includes(key)) {
120
160
  toAppend += body + '\n\n';
121
161
  }
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 });
162
+ const pointer = `- [${c.s.header}](${archiveFile}) — moved out of the index ${stamp} (full detail in file)`;
163
+ removeIdx.set(c.i, pointer);
164
+ archived.push({ section: c.s.header, lines: c.s.lines.length, chars: c.chars });
124
165
  projected -= (c.s.lines.length - 1);
166
+ projectedChars -= (body.length - pointer.length);
125
167
  }
126
168
 
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 };
169
+ if (!archived.length) return { overBudget: true, lineCount, charCount, budgetLines, budgetChars, archived: [], note: 'over budget but no fat inline sections found' };
170
+ if (dryRun) return { overBudget: true, lineCount, charCount, projectedLines: projected, projectedChars, budgetLines, budgetChars, wouldArchive: archived, dryRun: true };
129
171
 
130
172
  const out = [];
131
173
  for (let i = 0; i < sections.length; i++) {
@@ -137,16 +179,26 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
137
179
  out.push(MEMORY_SCHEMA_MARKER);
138
180
  }
139
181
 
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`;
182
+ const fm = `---\nname: Memory index archive (${stamp})\ndescription: Fat sections moved out of MEMORY.md to keep the loaded index under ${budgetLines} lines and ${budgetChars} characters. Nothing deleted; pointers remain in MEMORY.md.\nmetadata:\n type: reference\n---\n`;
141
183
  const base = priorArchive || fm;
142
184
  if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
143
- await atomicWrite(mdPath, out.join('\n'));
185
+ const newText = out.join('\n');
186
+ await atomicWrite(mdPath, newText);
144
187
 
145
188
  // Only reached when tidyIndex actually changed something (both earlier
146
189
  // no-op paths — under budget, or over budget with nothing archivable —
147
190
  // return before this point, and dryRun never writes). The event should
148
191
  // 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 };
192
+ await appendEvent('tidy_ran', {
193
+ archived_count: archived.length,
194
+ from_lines: lineCount, to_lines: out.length,
195
+ from_chars: charCount, to_chars: newText.length,
196
+ });
197
+
198
+ return {
199
+ overBudget: true,
200
+ lineCount, newLineCount: out.length,
201
+ charCount, newCharCount: newText.length,
202
+ budgetLines, budgetChars, archived, archiveFile,
203
+ };
152
204
  }