memoir-cli 3.17.0 → 3.17.2
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 +1 -1
- package/src/commands/tidy.js +75 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.17.
|
|
3
|
+
"version": "3.17.2",
|
|
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",
|
package/src/commands/tidy.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
// Lean-memory: keep the loaded memory index (MEMORY.md) under a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
//
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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', {
|
|
150
|
-
|
|
151
|
-
|
|
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
|
}
|