claudeos-core 2.2.0 → 2.3.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/CHANGELOG.md +1664 -907
- package/CONTRIBUTING.md +92 -92
- package/README.de.md +28 -0
- package/README.es.md +28 -0
- package/README.fr.md +28 -0
- package/README.hi.md +28 -0
- package/README.ja.md +28 -0
- package/README.ko.md +1014 -986
- package/README.md +1016 -987
- package/README.ru.md +28 -0
- package/README.vi.md +1015 -987
- package/README.zh-CN.md +28 -0
- package/bin/cli.js +152 -148
- package/bin/commands/init.js +1673 -1554
- package/bin/commands/lint.js +62 -0
- package/bin/commands/memory.js +438 -438
- package/bin/lib/cli-utils.js +206 -206
- package/claude-md-validator/index.js +184 -0
- package/claude-md-validator/reporter.js +66 -0
- package/claude-md-validator/structural-checks.js +528 -0
- package/content-validator/index.js +666 -441
- package/lib/expected-guides.js +23 -23
- package/lib/expected-outputs.js +90 -90
- package/lib/language-config.js +35 -35
- package/lib/memory-scaffold.js +1058 -1054
- package/lib/plan-parser.js +165 -165
- package/lib/staged-rules.js +118 -118
- package/manifest-generator/index.js +174 -174
- package/package.json +90 -87
- package/pass-json-validator/index.js +337 -337
- package/pass-prompts/templates/common/claude-md-scaffold.md +52 -10
- package/pass-prompts/templates/common/pass3-footer.md +402 -224
- package/pass-prompts/templates/common/pass3b-core-header.md +43 -0
- package/pass-prompts/templates/common/pass4.md +375 -305
- package/pass-prompts/templates/common/staging-override.md +26 -26
- package/pass-prompts/templates/node-vite/pass1.md +117 -117
- package/pass-prompts/templates/node-vite/pass2.md +78 -78
- package/pass-prompts/templates/python-flask/pass1.md +119 -119
- package/pass-prompts/templates/python-flask/pass2.md +85 -85
- package/plan-installer/domain-grouper.js +76 -76
- package/plan-installer/index.js +137 -137
- package/plan-installer/prompt-generator.js +188 -145
- package/plan-installer/scanners/scan-frontend.js +505 -473
- package/plan-installer/scanners/scan-java.js +226 -226
- package/plan-installer/scanners/scan-node.js +57 -57
- package/plan-installer/scanners/scan-python.js +85 -85
- package/plan-installer/stack-detector.js +482 -482
- package/plan-installer/structure-scanner.js +65 -65
- package/sync-checker/index.js +177 -177
package/bin/commands/memory.js
CHANGED
|
@@ -1,438 +1,438 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ClaudeOS-Core — Memory command (L4 memory)
|
|
3
|
-
*
|
|
4
|
-
* Subcommands:
|
|
5
|
-
* memory compact — apply 4-stage compaction to decision-log.md / failure-patterns.md
|
|
6
|
-
* memory score — recompute importance of failure-patterns.md entries
|
|
7
|
-
* memory propose-rules — analyze failure patterns, append suggestions to auto-rule-update.md
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const path = require("path");
|
|
11
|
-
const fs = require("fs");
|
|
12
|
-
const { PROJECT_ROOT, log } = require("../lib/cli-utils");
|
|
13
|
-
const { readFileSafe, writeFileSafe, ensureDir } = require("../../lib/safe-fs");
|
|
14
|
-
|
|
15
|
-
const MEMORY_DIR = path.join(PROJECT_ROOT, "claudeos-core/memory");
|
|
16
|
-
const GEN_DIR = path.join(PROJECT_ROOT, "claudeos-core/generated");
|
|
17
|
-
|
|
18
|
-
const FILE_SIZE_LINE_CAP = 400;
|
|
19
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Parse a memory file into header + list of `## ...` entries.
|
|
23
|
-
* Returns { header, entries: [{ heading, body, meta }] }
|
|
24
|
-
* meta.lastSeen (Date|null), meta.frequency (number|null), meta.importance (number|null)
|
|
25
|
-
*/
|
|
26
|
-
function parseEntries(raw) {
|
|
27
|
-
const lines = raw.split("\n");
|
|
28
|
-
const headerLines = [];
|
|
29
|
-
let i = 0;
|
|
30
|
-
// Track whether we're inside a fenced code block so `## ...` lines
|
|
31
|
-
// that appear as example markdown inside ```...``` are treated as
|
|
32
|
-
// body content, not as new entry headings.
|
|
33
|
-
let inFence = false;
|
|
34
|
-
const FENCE_RE = /^(```|~~~)/;
|
|
35
|
-
for (; i < lines.length; i++) {
|
|
36
|
-
if (FENCE_RE.test(lines[i])) inFence = !inFence;
|
|
37
|
-
if (!inFence && /^##\s+/.test(lines[i])) break;
|
|
38
|
-
headerLines.push(lines[i]);
|
|
39
|
-
}
|
|
40
|
-
const entries = [];
|
|
41
|
-
let cur = null;
|
|
42
|
-
for (; i < lines.length; i++) {
|
|
43
|
-
if (FENCE_RE.test(lines[i])) inFence = !inFence;
|
|
44
|
-
if (!inFence && /^##\s+/.test(lines[i])) {
|
|
45
|
-
if (cur) entries.push(cur);
|
|
46
|
-
cur = { heading: lines[i], body: [] };
|
|
47
|
-
} else if (cur) {
|
|
48
|
-
cur.body.push(lines[i]);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (cur) entries.push(cur);
|
|
52
|
-
|
|
53
|
-
for (const e of entries) {
|
|
54
|
-
const full = [e.heading, ...e.body].join("\n");
|
|
55
|
-
e.meta = {
|
|
56
|
-
lastSeen: parseDate(full),
|
|
57
|
-
frequency: parseField(full, "frequency") ?? parseField(full, "count"),
|
|
58
|
-
importance: parseField(full, "importance"),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
return { header: headerLines.join("\n"), entries };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parseDate(text) {
|
|
65
|
-
// Match a proper field line: `- last seen: 2026-04-17`, `- **last seen**: ...`,
|
|
66
|
-
// `- date: ...`. Anchored to line start (`^...m`) and a leading hyphen so
|
|
67
|
-
// a date mentioned inside a verbose `Fix:` line (e.g. "apply at 2026-04-17")
|
|
68
|
-
// doesn't accidentally get picked up as the entry's lastSeen.
|
|
69
|
-
const fieldRe = /^[\s*-]+\*{0,2}\s*(?:last\s*seen|date)\s*\*{0,2}\s*[:=]\s*(\d{4}-\d{2}-\d{2})/im;
|
|
70
|
-
const m = text.match(fieldRe);
|
|
71
|
-
if (m) return new Date(m[1]);
|
|
72
|
-
// Fallback: decision-log style heading `## 2026-04-17 — ...`
|
|
73
|
-
const h = text.match(/^##\s+(\d{4}-\d{2}-\d{2})/m);
|
|
74
|
-
if (h) return new Date(h[1]);
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function parseField(text, key) {
|
|
79
|
-
// Anchor to field-line format: `- <key>: N`, `- **<key>**: N`, with optional
|
|
80
|
-
// leading whitespace/markdown. Requires start-of-line + a hyphen (or asterisk)
|
|
81
|
-
// so pseudo-fields inside a Fix body (e.g. "set the frequency: 10 in config")
|
|
82
|
-
// are not picked up as the entry's real frequency value.
|
|
83
|
-
// The callers order auto-scored lines (e.g. `- **importance**: 10`) ahead
|
|
84
|
-
// of any user-entered plain line, so first-match still favors auto-scored.
|
|
85
|
-
const re = new RegExp(`^[\\s*-]+\\*{0,2}\\s*${key}\\*{0,2}\\s*[:=]\\s*([0-9]+)`, "im");
|
|
86
|
-
const m = text.match(re);
|
|
87
|
-
return m ? parseInt(m[1], 10) : null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function serialize({ header, entries }) {
|
|
91
|
-
// Reconcile meta values back into body lines before writing.
|
|
92
|
-
// Stage 2 (merge duplicates) updates `e.meta.frequency` in memory but the
|
|
93
|
-
// body still carries the original `- frequency: N` line from the first
|
|
94
|
-
// occurrence. Without rewriting those lines, the merged frequency total
|
|
95
|
-
// would be silently discarded on disk. Same for `last seen:`.
|
|
96
|
-
const body = entries.map(e => {
|
|
97
|
-
const updatedBody = e.body.map(line => {
|
|
98
|
-
// Preserve indentation/prefix — match `[- **]frequency:[**] N` etc.
|
|
99
|
-
if (/^\s*-\s+\*?\*?frequency\*?\*?:\s*/i.test(line)
|
|
100
|
-
&& e.meta.frequency !== null && e.meta.frequency !== undefined) {
|
|
101
|
-
return line.replace(
|
|
102
|
-
/^(\s*-\s+\*?\*?frequency\*?\*?:\s*).*/i,
|
|
103
|
-
`$1${e.meta.frequency}`
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
if (/^\s*-\s+\*?\*?last seen\*?\*?:\s*/i.test(line)
|
|
107
|
-
&& e.meta.lastSeen instanceof Date && !isNaN(e.meta.lastSeen)) {
|
|
108
|
-
const iso = e.meta.lastSeen.toISOString().slice(0, 10);
|
|
109
|
-
return line.replace(
|
|
110
|
-
/^(\s*-\s+\*?\*?last seen\*?\*?:\s*).*/i,
|
|
111
|
-
`$1${iso}`
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
return line;
|
|
115
|
-
});
|
|
116
|
-
return [e.heading, ...updatedBody].join("\n");
|
|
117
|
-
}).join("\n");
|
|
118
|
-
return header + (body ? "\n" + body : "") + (body.endsWith("\n") ? "" : "\n");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function daysSince(date) {
|
|
122
|
-
if (!date) return Infinity;
|
|
123
|
-
return (Date.now() - date.getTime()) / DAY_MS;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function loadActiveRulePaths() {
|
|
127
|
-
const manifestPath = path.join(GEN_DIR, "rule-manifest.json");
|
|
128
|
-
if (!fs.existsSync(manifestPath)) return new Set();
|
|
129
|
-
try {
|
|
130
|
-
const mf = JSON.parse(readFileSafe(manifestPath, "{}"));
|
|
131
|
-
const paths = new Set();
|
|
132
|
-
for (const r of mf.rules || []) {
|
|
133
|
-
if (Array.isArray(r.paths)) r.paths.forEach(p => paths.add(p));
|
|
134
|
-
}
|
|
135
|
-
return paths;
|
|
136
|
-
} catch (_e) {
|
|
137
|
-
return new Set();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function isPreserved(entry, activeRulePaths) {
|
|
142
|
-
if ((entry.meta.importance ?? 0) >= 7) return true;
|
|
143
|
-
if (daysSince(entry.meta.lastSeen) < 30) return true;
|
|
144
|
-
const full = [entry.heading, ...entry.body].join("\n");
|
|
145
|
-
for (const p of activeRulePaths) {
|
|
146
|
-
// Skip glob patterns (`**/*`, `src/**/*.java`, etc.) — they are
|
|
147
|
-
// applicability scopes, not anchor references. A literal glob inside
|
|
148
|
-
// entry body (e.g. a Fix line explaining a glob) would otherwise make
|
|
149
|
-
// every matching low-importance entry permanently preserved. Only
|
|
150
|
-
// concrete file paths count as anchors.
|
|
151
|
-
if (!p || /[*?[\]]/.test(p)) continue;
|
|
152
|
-
if (full.includes(p)) return true;
|
|
153
|
-
}
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function compactFile(filePath, activeRulePaths) {
|
|
158
|
-
if (!fs.existsSync(filePath)) return { changed: false, reason: "missing" };
|
|
159
|
-
const raw = readFileSafe(filePath);
|
|
160
|
-
const parsed = parseEntries(raw);
|
|
161
|
-
const before = parsed.entries.length;
|
|
162
|
-
|
|
163
|
-
// Stage 1: Summarize aged entries (>30 days, not preserved)
|
|
164
|
-
// Safety: skip entries with no parseable lastSeen date — don't assume "aged" from absence
|
|
165
|
-
// Preserve metadata lines (frequency/last seen/importance) so later stages
|
|
166
|
-
// (Stage 3 drop, next compact run) can still parse them. Only drop the
|
|
167
|
-
// verbose prose lines.
|
|
168
|
-
//
|
|
169
|
-
// Fix line matching is anchored to the field format `- Fix: ...` or
|
|
170
|
-
// `- **fix**: ...` (optionally also `solution:`) so that verbose prose
|
|
171
|
-
// containing words like "solve" or "fixing" does not get picked up as
|
|
172
|
-
// the fix line by accident.
|
|
173
|
-
const FIX_LINE_RE = /^\s*-\s*\*{0,2}\s*(fix|solution)\s*\*{0,2}\s*[:=]/i;
|
|
174
|
-
for (const e of parsed.entries) {
|
|
175
|
-
if (!e.meta.lastSeen) continue;
|
|
176
|
-
if (!isPreserved(e, activeRulePaths) && daysSince(e.meta.lastSeen) > 30) {
|
|
177
|
-
const metaLines = e.body.filter(l =>
|
|
178
|
-
/^\s*-\s*\*{0,2}\s*(frequency|last\s*seen|importance)\s*\*{0,2}\s*[:=]/i.test(l)
|
|
179
|
-
);
|
|
180
|
-
const fixLine = e.body.find(l => FIX_LINE_RE.test(l)) || "- (fix omitted)";
|
|
181
|
-
// Summary marker formatted as a proper markdown list item so that:
|
|
182
|
-
// 1. parseEntries can re-read it as a body line in future compactions
|
|
183
|
-
// 2. GitHub/IDE markdown renderers format it consistently with the
|
|
184
|
-
// surrounding list (previously an inline italic string broke the
|
|
185
|
-
// list flow visually).
|
|
186
|
-
const summaryLine = `- _Summarized on ${new Date().toISOString().slice(0, 10)} — original body dropped._`;
|
|
187
|
-
e.body = [
|
|
188
|
-
...metaLines,
|
|
189
|
-
summaryLine,
|
|
190
|
-
fixLine,
|
|
191
|
-
];
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Stage 2: Merge duplicates (by heading)
|
|
196
|
-
const map = new Map();
|
|
197
|
-
for (const e of parsed.entries) {
|
|
198
|
-
const key = e.heading.trim();
|
|
199
|
-
if (map.has(key)) {
|
|
200
|
-
const prev = map.get(key);
|
|
201
|
-
const prevFreq = prev.meta.frequency || 1;
|
|
202
|
-
const curFreq = e.meta.frequency || 1;
|
|
203
|
-
prev.meta.frequency = prevFreq + curFreq;
|
|
204
|
-
if ((e.meta.lastSeen?.getTime() || 0) > (prev.meta.lastSeen?.getTime() || 0)) {
|
|
205
|
-
prev.meta.lastSeen = e.meta.lastSeen;
|
|
206
|
-
prev.body = e.body;
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
map.set(key, e);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
parsed.entries = Array.from(map.values());
|
|
213
|
-
|
|
214
|
-
// Stage 3: Drop low-importance (<3) older than 60 days
|
|
215
|
-
// Safety: require parseable lastSeen AND explicit low importance — don't drop undated entries.
|
|
216
|
-
// Also respect isPreserved() — an entry anchored by an active rule (concrete
|
|
217
|
-
// file path match) must not be silently dropped even if it scored low and
|
|
218
|
-
// is old. That anchor is the whole point of the rule-memory connection.
|
|
219
|
-
parsed.entries = parsed.entries.filter(e => {
|
|
220
|
-
if (!e.meta.lastSeen) return true;
|
|
221
|
-
if (isPreserved(e, activeRulePaths)) return true;
|
|
222
|
-
if ((e.meta.importance ?? 5) < 3 && daysSince(e.meta.lastSeen) > 60) return false;
|
|
223
|
-
return true;
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// Stage 4: Enforce line cap
|
|
227
|
-
let totalLines = parsed.header.split("\n").length + parsed.entries.reduce((a, e) => a + 1 + e.body.length, 0);
|
|
228
|
-
if (totalLines > FILE_SIZE_LINE_CAP) {
|
|
229
|
-
parsed.entries.sort((a, b) => {
|
|
230
|
-
const ap = isPreserved(a, activeRulePaths) ? 1 : 0;
|
|
231
|
-
const bp = isPreserved(b, activeRulePaths) ? 1 : 0;
|
|
232
|
-
if (ap !== bp) return bp - ap;
|
|
233
|
-
return (b.meta.lastSeen?.getTime() || 0) - (a.meta.lastSeen?.getTime() || 0);
|
|
234
|
-
});
|
|
235
|
-
while (totalLines > FILE_SIZE_LINE_CAP && parsed.entries.length > 0) {
|
|
236
|
-
const dropped = parsed.entries.pop();
|
|
237
|
-
totalLines -= (1 + dropped.body.length);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const after = parsed.entries.length;
|
|
242
|
-
writeFileSafe(filePath, serialize(parsed));
|
|
243
|
-
return { changed: true, before, after };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function cmdCompact() {
|
|
247
|
-
ensureDir(MEMORY_DIR);
|
|
248
|
-
const activeRulePaths = loadActiveRulePaths();
|
|
249
|
-
|
|
250
|
-
const files = ["decision-log.md", "failure-patterns.md"];
|
|
251
|
-
const summaries = [];
|
|
252
|
-
for (const f of files) {
|
|
253
|
-
const r = compactFile(path.join(MEMORY_DIR, f), activeRulePaths);
|
|
254
|
-
summaries.push({ file: f, ...r });
|
|
255
|
-
if (r.changed) log(` ✅ ${f}: ${r.before} → ${r.after} entries`);
|
|
256
|
-
else log(` ⏭️ ${f}: ${r.reason}`);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Update compaction.md "Last Compaction" section.
|
|
260
|
-
// Replace ONLY the "## Last Compaction" section (up to next `##` heading or EOF).
|
|
261
|
-
// Preserves any user-added content that follows.
|
|
262
|
-
const compPath = path.join(MEMORY_DIR, "compaction.md");
|
|
263
|
-
if (fs.existsSync(compPath)) {
|
|
264
|
-
const raw = readFileSafe(compPath);
|
|
265
|
-
const marker = "## Last Compaction";
|
|
266
|
-
const body = `${marker}\nRan at ${new Date().toISOString()}\n${summaries.map(s => `- ${s.file}: ${s.before} → ${s.after}`).join("\n")}\n`;
|
|
267
|
-
const idx = raw.indexOf(marker);
|
|
268
|
-
let updated;
|
|
269
|
-
if (idx >= 0) {
|
|
270
|
-
// Find next `##` heading after marker (or EOF)
|
|
271
|
-
const afterMarker = raw.slice(idx + marker.length);
|
|
272
|
-
const nextHeadingRel = afterMarker.search(/\n## /);
|
|
273
|
-
const sectionEnd = nextHeadingRel >= 0 ? idx + marker.length + nextHeadingRel + 1 : raw.length;
|
|
274
|
-
updated = raw.slice(0, idx) + body + raw.slice(sectionEnd);
|
|
275
|
-
} else {
|
|
276
|
-
updated = raw + (raw.endsWith("\n") ? "" : "\n") + body;
|
|
277
|
-
}
|
|
278
|
-
writeFileSafe(compPath, updated);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function cmdScore() {
|
|
283
|
-
const file = path.join(MEMORY_DIR, "failure-patterns.md");
|
|
284
|
-
if (!fs.existsSync(file)) {
|
|
285
|
-
log(" ⏭️ failure-patterns.md not found");
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
const raw = readFileSafe(file);
|
|
289
|
-
const parsed = parseEntries(raw);
|
|
290
|
-
let scored = 0;
|
|
291
|
-
for (const e of parsed.entries) {
|
|
292
|
-
const freq = e.meta.frequency || 1;
|
|
293
|
-
const ageDays = daysSince(e.meta.lastSeen);
|
|
294
|
-
const recency = ageDays === Infinity ? 0 : Math.max(0, 1 - ageDays / 90);
|
|
295
|
-
const importance = Math.min(10, Math.round((freq * 1.5) + (recency * 5)));
|
|
296
|
-
const line = `- **importance**: ${importance} _(auto-scored ${new Date().toISOString().slice(0, 10)}, freq=${freq}, recency=${recency.toFixed(2)})_`;
|
|
297
|
-
|
|
298
|
-
// Remove ALL existing importance lines (both the bold auto-scored variant
|
|
299
|
-
// and the plain `- importance: N` variant). Without this, the first score
|
|
300
|
-
// run leaves two importance lines — the auto-scored one at the top and
|
|
301
|
-
// the original user-written one below it — which is confusing and makes
|
|
302
|
-
// the file look like it has conflicting values.
|
|
303
|
-
const IMPORTANCE_LINE_RE = /^\s*-\s*\*{0,2}\s*importance\s*\*{0,2}\s*[:=]/i;
|
|
304
|
-
e.body = e.body.filter(l => !IMPORTANCE_LINE_RE.test(l));
|
|
305
|
-
e.body.unshift(line);
|
|
306
|
-
e.meta.importance = importance;
|
|
307
|
-
scored++;
|
|
308
|
-
}
|
|
309
|
-
writeFileSafe(file, serialize(parsed));
|
|
310
|
-
log(` ✅ Scored ${scored} entries in failure-patterns.md`);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Compute a confidence score for an auto-rule-update proposal.
|
|
315
|
-
*
|
|
316
|
-
* Replaces the v1 formula `min(1, freq/10 + imp/20)` which saturated too fast
|
|
317
|
-
* (freq=10 → 1.0 regardless of recency/importance). New formula uses a
|
|
318
|
-
* sigmoid on weighted evidence + an anchor-match multiplier.
|
|
319
|
-
*
|
|
320
|
-
* Inputs:
|
|
321
|
-
* freq — number of times the pattern was seen (>= 3 at this point)
|
|
322
|
-
* imp — importance score 1..10 (null → treated as below-average)
|
|
323
|
-
* anchored — true if an active rule path actually matches this pattern's body
|
|
324
|
-
*
|
|
325
|
-
* Design:
|
|
326
|
-
* evidence = 1.5 * freq + 0.5 * imp (freq weighs 3× importance)
|
|
327
|
-
* sigmoid(x) = 1 / (1 + exp(-k * (x - x0))) (x0=8 center, k=0.35 slope)
|
|
328
|
-
* unanchored × 0.6 (we're less confident which rule is affected)
|
|
329
|
-
* low evidence cap: if imp is null/undefined, cap evidence at 6 so the
|
|
330
|
-
* sigmoid cannot exceed ~0.36 — importance must be set for a strong score.
|
|
331
|
-
*
|
|
332
|
-
* Rough calibration:
|
|
333
|
-
* freq=3, imp=5, anchored → ~0.35
|
|
334
|
-
* freq=5, imp=7, anchored → ~0.66
|
|
335
|
-
* freq=10, imp=8, anchored → ~0.91
|
|
336
|
-
* freq=10, imp=8, unanchored → ~0.55
|
|
337
|
-
* freq=3, imp=null, anchored → ~0.26
|
|
338
|
-
*/
|
|
339
|
-
function computeConfidence(freq, imp, anchored) {
|
|
340
|
-
const f = Math.max(0, freq || 0);
|
|
341
|
-
const hasImp = imp !== null && imp !== undefined;
|
|
342
|
-
const i = hasImp ? imp : 0;
|
|
343
|
-
let evidence = 1.5 * f + 0.5 * i;
|
|
344
|
-
// Penalise missing importance: cap evidence so sigmoid can't exceed ~0.36
|
|
345
|
-
if (!hasImp) evidence = Math.min(evidence, 6);
|
|
346
|
-
const k = 0.35;
|
|
347
|
-
const x0 = 8;
|
|
348
|
-
const sig = 1 / (1 + Math.exp(-k * (evidence - x0)));
|
|
349
|
-
const multiplier = anchored ? 1.0 : 0.6;
|
|
350
|
-
return Math.max(0, Math.min(1, sig * multiplier));
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function cmdProposeRules() {
|
|
354
|
-
const fpPath = path.join(MEMORY_DIR, "failure-patterns.md");
|
|
355
|
-
const proposalPath = path.join(MEMORY_DIR, "auto-rule-update.md");
|
|
356
|
-
if (!fs.existsSync(fpPath)) {
|
|
357
|
-
log(" ⏭️ failure-patterns.md not found");
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
const parsed = parseEntries(readFileSafe(fpPath));
|
|
361
|
-
const candidates = parsed.entries.filter(e => (e.meta.frequency || 0) >= 3);
|
|
362
|
-
if (candidates.length === 0) {
|
|
363
|
-
log(" ℹ️ No failure patterns with frequency >= 3 — nothing to propose");
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const activeRulePaths = loadActiveRulePaths();
|
|
368
|
-
const rulesArr = Array.from(activeRulePaths);
|
|
369
|
-
const META_LINE_RE = /^\s*-\s*\*{0,2}\s*(?:frequency|count|last\s*seen|importance)\*{0,2}\s*[:=]/i;
|
|
370
|
-
const proposals = [];
|
|
371
|
-
for (const e of candidates) {
|
|
372
|
-
const id = e.heading.replace(/^##\s+/, "").trim();
|
|
373
|
-
const body = e.body.join("\n");
|
|
374
|
-
// Same glob-skip logic as isPreserved(): a rule's `paths: ["**/*"]`
|
|
375
|
-
// is a scope, not a concrete anchor. Only count matches against
|
|
376
|
-
// literal file paths so we don't falsely anchor a pattern just because
|
|
377
|
-
// its body mentions a glob string.
|
|
378
|
-
const matchedRule = rulesArr.find(p => p && !/[*?[\]]/.test(p) && body.includes(p));
|
|
379
|
-
const anchored = !!matchedRule;
|
|
380
|
-
const affectedRule = matchedRule || "(no matching active rule — consider new rule)";
|
|
381
|
-
const confidence = computeConfidence(e.meta.frequency, e.meta.importance, anchored);
|
|
382
|
-
// Build a meaningful summary by skipping metadata and blank lines,
|
|
383
|
-
// taking up to 3 content-bearing lines. Metadata-only summaries
|
|
384
|
-
// (which happened when an entry's first 3 lines were frequency/last seen/importance)
|
|
385
|
-
// are useless for the reviewer.
|
|
386
|
-
const summary = e.body
|
|
387
|
-
.filter(l => l.trim() && !META_LINE_RE.test(l))
|
|
388
|
-
.slice(0, 3)
|
|
389
|
-
.join(" ")
|
|
390
|
-
.trim() || "(no body content)";
|
|
391
|
-
proposals.push({
|
|
392
|
-
id,
|
|
393
|
-
frequency: e.meta.frequency,
|
|
394
|
-
importance: e.meta.importance,
|
|
395
|
-
anchored,
|
|
396
|
-
affectedRule,
|
|
397
|
-
confidence: confidence.toFixed(2),
|
|
398
|
-
summary,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const existing = readFileSafe(proposalPath, "# Auto Rule Update Proposals\n");
|
|
403
|
-
const block = `\n## Generated at ${new Date().toISOString()}\n\n${proposals.map(p => `### ${p.id}\n- **Affected rule:** \`${p.affectedRule}\`${p.anchored ? "" : " _(unanchored — confidence reduced)_"}\n- **Frequency:** ${p.frequency ?? "?"} · **Importance:** ${p.importance ?? "?"} · **Confidence:** ${p.confidence}\n- **Summary:** ${p.summary}\n- **Proposed change:** <review failure-patterns.md "${p.id}" and edit the affected rule accordingly>\n`).join("\n")}\n`;
|
|
404
|
-
|
|
405
|
-
writeFileSafe(proposalPath, existing + block);
|
|
406
|
-
log(` ✅ Appended ${proposals.length} proposal(s) to auto-rule-update.md`);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function showHelp() {
|
|
410
|
-
log(`
|
|
411
|
-
Usage: npx claudeos-core memory <subcommand>
|
|
412
|
-
|
|
413
|
-
Subcommands:
|
|
414
|
-
compact Apply 4-stage compaction to decision-log.md and failure-patterns.md
|
|
415
|
-
score Recompute importance of failure-patterns.md entries (frequency × recency)
|
|
416
|
-
propose-rules Analyze failure patterns, append rule update suggestions to auto-rule-update.md
|
|
417
|
-
`);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async function cmdMemory(parsedArgs) {
|
|
421
|
-
const sub = process.argv.slice(3)[0];
|
|
422
|
-
switch (sub) {
|
|
423
|
-
case "compact": return cmdCompact();
|
|
424
|
-
case "score": return cmdScore();
|
|
425
|
-
case "propose-rules": return cmdProposeRules();
|
|
426
|
-
case undefined:
|
|
427
|
-
case "--help":
|
|
428
|
-
case "-h":
|
|
429
|
-
showHelp();
|
|
430
|
-
return;
|
|
431
|
-
default:
|
|
432
|
-
log(`Unknown memory subcommand: ${sub}`);
|
|
433
|
-
showHelp();
|
|
434
|
-
process.exit(1);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
module.exports = { cmdMemory, computeConfidence };
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeOS-Core — Memory command (L4 memory)
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* memory compact — apply 4-stage compaction to decision-log.md / failure-patterns.md
|
|
6
|
+
* memory score — recompute importance of failure-patterns.md entries
|
|
7
|
+
* memory propose-rules — analyze failure patterns, append suggestions to auto-rule-update.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const { PROJECT_ROOT, log } = require("../lib/cli-utils");
|
|
13
|
+
const { readFileSafe, writeFileSafe, ensureDir } = require("../../lib/safe-fs");
|
|
14
|
+
|
|
15
|
+
const MEMORY_DIR = path.join(PROJECT_ROOT, "claudeos-core/memory");
|
|
16
|
+
const GEN_DIR = path.join(PROJECT_ROOT, "claudeos-core/generated");
|
|
17
|
+
|
|
18
|
+
const FILE_SIZE_LINE_CAP = 400;
|
|
19
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a memory file into header + list of `## ...` entries.
|
|
23
|
+
* Returns { header, entries: [{ heading, body, meta }] }
|
|
24
|
+
* meta.lastSeen (Date|null), meta.frequency (number|null), meta.importance (number|null)
|
|
25
|
+
*/
|
|
26
|
+
function parseEntries(raw) {
|
|
27
|
+
const lines = raw.split("\n");
|
|
28
|
+
const headerLines = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
// Track whether we're inside a fenced code block so `## ...` lines
|
|
31
|
+
// that appear as example markdown inside ```...``` are treated as
|
|
32
|
+
// body content, not as new entry headings.
|
|
33
|
+
let inFence = false;
|
|
34
|
+
const FENCE_RE = /^(```|~~~)/;
|
|
35
|
+
for (; i < lines.length; i++) {
|
|
36
|
+
if (FENCE_RE.test(lines[i])) inFence = !inFence;
|
|
37
|
+
if (!inFence && /^##\s+/.test(lines[i])) break;
|
|
38
|
+
headerLines.push(lines[i]);
|
|
39
|
+
}
|
|
40
|
+
const entries = [];
|
|
41
|
+
let cur = null;
|
|
42
|
+
for (; i < lines.length; i++) {
|
|
43
|
+
if (FENCE_RE.test(lines[i])) inFence = !inFence;
|
|
44
|
+
if (!inFence && /^##\s+/.test(lines[i])) {
|
|
45
|
+
if (cur) entries.push(cur);
|
|
46
|
+
cur = { heading: lines[i], body: [] };
|
|
47
|
+
} else if (cur) {
|
|
48
|
+
cur.body.push(lines[i]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (cur) entries.push(cur);
|
|
52
|
+
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const full = [e.heading, ...e.body].join("\n");
|
|
55
|
+
e.meta = {
|
|
56
|
+
lastSeen: parseDate(full),
|
|
57
|
+
frequency: parseField(full, "frequency") ?? parseField(full, "count"),
|
|
58
|
+
importance: parseField(full, "importance"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { header: headerLines.join("\n"), entries };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseDate(text) {
|
|
65
|
+
// Match a proper field line: `- last seen: 2026-04-17`, `- **last seen**: ...`,
|
|
66
|
+
// `- date: ...`. Anchored to line start (`^...m`) and a leading hyphen so
|
|
67
|
+
// a date mentioned inside a verbose `Fix:` line (e.g. "apply at 2026-04-17")
|
|
68
|
+
// doesn't accidentally get picked up as the entry's lastSeen.
|
|
69
|
+
const fieldRe = /^[\s*-]+\*{0,2}\s*(?:last\s*seen|date)\s*\*{0,2}\s*[:=]\s*(\d{4}-\d{2}-\d{2})/im;
|
|
70
|
+
const m = text.match(fieldRe);
|
|
71
|
+
if (m) return new Date(m[1]);
|
|
72
|
+
// Fallback: decision-log style heading `## 2026-04-17 — ...`
|
|
73
|
+
const h = text.match(/^##\s+(\d{4}-\d{2}-\d{2})/m);
|
|
74
|
+
if (h) return new Date(h[1]);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseField(text, key) {
|
|
79
|
+
// Anchor to field-line format: `- <key>: N`, `- **<key>**: N`, with optional
|
|
80
|
+
// leading whitespace/markdown. Requires start-of-line + a hyphen (or asterisk)
|
|
81
|
+
// so pseudo-fields inside a Fix body (e.g. "set the frequency: 10 in config")
|
|
82
|
+
// are not picked up as the entry's real frequency value.
|
|
83
|
+
// The callers order auto-scored lines (e.g. `- **importance**: 10`) ahead
|
|
84
|
+
// of any user-entered plain line, so first-match still favors auto-scored.
|
|
85
|
+
const re = new RegExp(`^[\\s*-]+\\*{0,2}\\s*${key}\\*{0,2}\\s*[:=]\\s*([0-9]+)`, "im");
|
|
86
|
+
const m = text.match(re);
|
|
87
|
+
return m ? parseInt(m[1], 10) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serialize({ header, entries }) {
|
|
91
|
+
// Reconcile meta values back into body lines before writing.
|
|
92
|
+
// Stage 2 (merge duplicates) updates `e.meta.frequency` in memory but the
|
|
93
|
+
// body still carries the original `- frequency: N` line from the first
|
|
94
|
+
// occurrence. Without rewriting those lines, the merged frequency total
|
|
95
|
+
// would be silently discarded on disk. Same for `last seen:`.
|
|
96
|
+
const body = entries.map(e => {
|
|
97
|
+
const updatedBody = e.body.map(line => {
|
|
98
|
+
// Preserve indentation/prefix — match `[- **]frequency:[**] N` etc.
|
|
99
|
+
if (/^\s*-\s+\*?\*?frequency\*?\*?:\s*/i.test(line)
|
|
100
|
+
&& e.meta.frequency !== null && e.meta.frequency !== undefined) {
|
|
101
|
+
return line.replace(
|
|
102
|
+
/^(\s*-\s+\*?\*?frequency\*?\*?:\s*).*/i,
|
|
103
|
+
`$1${e.meta.frequency}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (/^\s*-\s+\*?\*?last seen\*?\*?:\s*/i.test(line)
|
|
107
|
+
&& e.meta.lastSeen instanceof Date && !isNaN(e.meta.lastSeen)) {
|
|
108
|
+
const iso = e.meta.lastSeen.toISOString().slice(0, 10);
|
|
109
|
+
return line.replace(
|
|
110
|
+
/^(\s*-\s+\*?\*?last seen\*?\*?:\s*).*/i,
|
|
111
|
+
`$1${iso}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
});
|
|
116
|
+
return [e.heading, ...updatedBody].join("\n");
|
|
117
|
+
}).join("\n");
|
|
118
|
+
return header + (body ? "\n" + body : "") + (body.endsWith("\n") ? "" : "\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function daysSince(date) {
|
|
122
|
+
if (!date) return Infinity;
|
|
123
|
+
return (Date.now() - date.getTime()) / DAY_MS;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadActiveRulePaths() {
|
|
127
|
+
const manifestPath = path.join(GEN_DIR, "rule-manifest.json");
|
|
128
|
+
if (!fs.existsSync(manifestPath)) return new Set();
|
|
129
|
+
try {
|
|
130
|
+
const mf = JSON.parse(readFileSafe(manifestPath, "{}"));
|
|
131
|
+
const paths = new Set();
|
|
132
|
+
for (const r of mf.rules || []) {
|
|
133
|
+
if (Array.isArray(r.paths)) r.paths.forEach(p => paths.add(p));
|
|
134
|
+
}
|
|
135
|
+
return paths;
|
|
136
|
+
} catch (_e) {
|
|
137
|
+
return new Set();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPreserved(entry, activeRulePaths) {
|
|
142
|
+
if ((entry.meta.importance ?? 0) >= 7) return true;
|
|
143
|
+
if (daysSince(entry.meta.lastSeen) < 30) return true;
|
|
144
|
+
const full = [entry.heading, ...entry.body].join("\n");
|
|
145
|
+
for (const p of activeRulePaths) {
|
|
146
|
+
// Skip glob patterns (`**/*`, `src/**/*.java`, etc.) — they are
|
|
147
|
+
// applicability scopes, not anchor references. A literal glob inside
|
|
148
|
+
// entry body (e.g. a Fix line explaining a glob) would otherwise make
|
|
149
|
+
// every matching low-importance entry permanently preserved. Only
|
|
150
|
+
// concrete file paths count as anchors.
|
|
151
|
+
if (!p || /[*?[\]]/.test(p)) continue;
|
|
152
|
+
if (full.includes(p)) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compactFile(filePath, activeRulePaths) {
|
|
158
|
+
if (!fs.existsSync(filePath)) return { changed: false, reason: "missing" };
|
|
159
|
+
const raw = readFileSafe(filePath);
|
|
160
|
+
const parsed = parseEntries(raw);
|
|
161
|
+
const before = parsed.entries.length;
|
|
162
|
+
|
|
163
|
+
// Stage 1: Summarize aged entries (>30 days, not preserved)
|
|
164
|
+
// Safety: skip entries with no parseable lastSeen date — don't assume "aged" from absence
|
|
165
|
+
// Preserve metadata lines (frequency/last seen/importance) so later stages
|
|
166
|
+
// (Stage 3 drop, next compact run) can still parse them. Only drop the
|
|
167
|
+
// verbose prose lines.
|
|
168
|
+
//
|
|
169
|
+
// Fix line matching is anchored to the field format `- Fix: ...` or
|
|
170
|
+
// `- **fix**: ...` (optionally also `solution:`) so that verbose prose
|
|
171
|
+
// containing words like "solve" or "fixing" does not get picked up as
|
|
172
|
+
// the fix line by accident.
|
|
173
|
+
const FIX_LINE_RE = /^\s*-\s*\*{0,2}\s*(fix|solution)\s*\*{0,2}\s*[:=]/i;
|
|
174
|
+
for (const e of parsed.entries) {
|
|
175
|
+
if (!e.meta.lastSeen) continue;
|
|
176
|
+
if (!isPreserved(e, activeRulePaths) && daysSince(e.meta.lastSeen) > 30) {
|
|
177
|
+
const metaLines = e.body.filter(l =>
|
|
178
|
+
/^\s*-\s*\*{0,2}\s*(frequency|last\s*seen|importance)\s*\*{0,2}\s*[:=]/i.test(l)
|
|
179
|
+
);
|
|
180
|
+
const fixLine = e.body.find(l => FIX_LINE_RE.test(l)) || "- (fix omitted)";
|
|
181
|
+
// Summary marker formatted as a proper markdown list item so that:
|
|
182
|
+
// 1. parseEntries can re-read it as a body line in future compactions
|
|
183
|
+
// 2. GitHub/IDE markdown renderers format it consistently with the
|
|
184
|
+
// surrounding list (previously an inline italic string broke the
|
|
185
|
+
// list flow visually).
|
|
186
|
+
const summaryLine = `- _Summarized on ${new Date().toISOString().slice(0, 10)} — original body dropped._`;
|
|
187
|
+
e.body = [
|
|
188
|
+
...metaLines,
|
|
189
|
+
summaryLine,
|
|
190
|
+
fixLine,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Stage 2: Merge duplicates (by heading)
|
|
196
|
+
const map = new Map();
|
|
197
|
+
for (const e of parsed.entries) {
|
|
198
|
+
const key = e.heading.trim();
|
|
199
|
+
if (map.has(key)) {
|
|
200
|
+
const prev = map.get(key);
|
|
201
|
+
const prevFreq = prev.meta.frequency || 1;
|
|
202
|
+
const curFreq = e.meta.frequency || 1;
|
|
203
|
+
prev.meta.frequency = prevFreq + curFreq;
|
|
204
|
+
if ((e.meta.lastSeen?.getTime() || 0) > (prev.meta.lastSeen?.getTime() || 0)) {
|
|
205
|
+
prev.meta.lastSeen = e.meta.lastSeen;
|
|
206
|
+
prev.body = e.body;
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
map.set(key, e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
parsed.entries = Array.from(map.values());
|
|
213
|
+
|
|
214
|
+
// Stage 3: Drop low-importance (<3) older than 60 days
|
|
215
|
+
// Safety: require parseable lastSeen AND explicit low importance — don't drop undated entries.
|
|
216
|
+
// Also respect isPreserved() — an entry anchored by an active rule (concrete
|
|
217
|
+
// file path match) must not be silently dropped even if it scored low and
|
|
218
|
+
// is old. That anchor is the whole point of the rule-memory connection.
|
|
219
|
+
parsed.entries = parsed.entries.filter(e => {
|
|
220
|
+
if (!e.meta.lastSeen) return true;
|
|
221
|
+
if (isPreserved(e, activeRulePaths)) return true;
|
|
222
|
+
if ((e.meta.importance ?? 5) < 3 && daysSince(e.meta.lastSeen) > 60) return false;
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Stage 4: Enforce line cap
|
|
227
|
+
let totalLines = parsed.header.split("\n").length + parsed.entries.reduce((a, e) => a + 1 + e.body.length, 0);
|
|
228
|
+
if (totalLines > FILE_SIZE_LINE_CAP) {
|
|
229
|
+
parsed.entries.sort((a, b) => {
|
|
230
|
+
const ap = isPreserved(a, activeRulePaths) ? 1 : 0;
|
|
231
|
+
const bp = isPreserved(b, activeRulePaths) ? 1 : 0;
|
|
232
|
+
if (ap !== bp) return bp - ap;
|
|
233
|
+
return (b.meta.lastSeen?.getTime() || 0) - (a.meta.lastSeen?.getTime() || 0);
|
|
234
|
+
});
|
|
235
|
+
while (totalLines > FILE_SIZE_LINE_CAP && parsed.entries.length > 0) {
|
|
236
|
+
const dropped = parsed.entries.pop();
|
|
237
|
+
totalLines -= (1 + dropped.body.length);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const after = parsed.entries.length;
|
|
242
|
+
writeFileSafe(filePath, serialize(parsed));
|
|
243
|
+
return { changed: true, before, after };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function cmdCompact() {
|
|
247
|
+
ensureDir(MEMORY_DIR);
|
|
248
|
+
const activeRulePaths = loadActiveRulePaths();
|
|
249
|
+
|
|
250
|
+
const files = ["decision-log.md", "failure-patterns.md"];
|
|
251
|
+
const summaries = [];
|
|
252
|
+
for (const f of files) {
|
|
253
|
+
const r = compactFile(path.join(MEMORY_DIR, f), activeRulePaths);
|
|
254
|
+
summaries.push({ file: f, ...r });
|
|
255
|
+
if (r.changed) log(` ✅ ${f}: ${r.before} → ${r.after} entries`);
|
|
256
|
+
else log(` ⏭️ ${f}: ${r.reason}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Update compaction.md "Last Compaction" section.
|
|
260
|
+
// Replace ONLY the "## Last Compaction" section (up to next `##` heading or EOF).
|
|
261
|
+
// Preserves any user-added content that follows.
|
|
262
|
+
const compPath = path.join(MEMORY_DIR, "compaction.md");
|
|
263
|
+
if (fs.existsSync(compPath)) {
|
|
264
|
+
const raw = readFileSafe(compPath);
|
|
265
|
+
const marker = "## Last Compaction";
|
|
266
|
+
const body = `${marker}\nRan at ${new Date().toISOString()}\n${summaries.map(s => `- ${s.file}: ${s.before} → ${s.after}`).join("\n")}\n`;
|
|
267
|
+
const idx = raw.indexOf(marker);
|
|
268
|
+
let updated;
|
|
269
|
+
if (idx >= 0) {
|
|
270
|
+
// Find next `##` heading after marker (or EOF)
|
|
271
|
+
const afterMarker = raw.slice(idx + marker.length);
|
|
272
|
+
const nextHeadingRel = afterMarker.search(/\n## /);
|
|
273
|
+
const sectionEnd = nextHeadingRel >= 0 ? idx + marker.length + nextHeadingRel + 1 : raw.length;
|
|
274
|
+
updated = raw.slice(0, idx) + body + raw.slice(sectionEnd);
|
|
275
|
+
} else {
|
|
276
|
+
updated = raw + (raw.endsWith("\n") ? "" : "\n") + body;
|
|
277
|
+
}
|
|
278
|
+
writeFileSafe(compPath, updated);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function cmdScore() {
|
|
283
|
+
const file = path.join(MEMORY_DIR, "failure-patterns.md");
|
|
284
|
+
if (!fs.existsSync(file)) {
|
|
285
|
+
log(" ⏭️ failure-patterns.md not found");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const raw = readFileSafe(file);
|
|
289
|
+
const parsed = parseEntries(raw);
|
|
290
|
+
let scored = 0;
|
|
291
|
+
for (const e of parsed.entries) {
|
|
292
|
+
const freq = e.meta.frequency || 1;
|
|
293
|
+
const ageDays = daysSince(e.meta.lastSeen);
|
|
294
|
+
const recency = ageDays === Infinity ? 0 : Math.max(0, 1 - ageDays / 90);
|
|
295
|
+
const importance = Math.min(10, Math.round((freq * 1.5) + (recency * 5)));
|
|
296
|
+
const line = `- **importance**: ${importance} _(auto-scored ${new Date().toISOString().slice(0, 10)}, freq=${freq}, recency=${recency.toFixed(2)})_`;
|
|
297
|
+
|
|
298
|
+
// Remove ALL existing importance lines (both the bold auto-scored variant
|
|
299
|
+
// and the plain `- importance: N` variant). Without this, the first score
|
|
300
|
+
// run leaves two importance lines — the auto-scored one at the top and
|
|
301
|
+
// the original user-written one below it — which is confusing and makes
|
|
302
|
+
// the file look like it has conflicting values.
|
|
303
|
+
const IMPORTANCE_LINE_RE = /^\s*-\s*\*{0,2}\s*importance\s*\*{0,2}\s*[:=]/i;
|
|
304
|
+
e.body = e.body.filter(l => !IMPORTANCE_LINE_RE.test(l));
|
|
305
|
+
e.body.unshift(line);
|
|
306
|
+
e.meta.importance = importance;
|
|
307
|
+
scored++;
|
|
308
|
+
}
|
|
309
|
+
writeFileSafe(file, serialize(parsed));
|
|
310
|
+
log(` ✅ Scored ${scored} entries in failure-patterns.md`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compute a confidence score for an auto-rule-update proposal.
|
|
315
|
+
*
|
|
316
|
+
* Replaces the v1 formula `min(1, freq/10 + imp/20)` which saturated too fast
|
|
317
|
+
* (freq=10 → 1.0 regardless of recency/importance). New formula uses a
|
|
318
|
+
* sigmoid on weighted evidence + an anchor-match multiplier.
|
|
319
|
+
*
|
|
320
|
+
* Inputs:
|
|
321
|
+
* freq — number of times the pattern was seen (>= 3 at this point)
|
|
322
|
+
* imp — importance score 1..10 (null → treated as below-average)
|
|
323
|
+
* anchored — true if an active rule path actually matches this pattern's body
|
|
324
|
+
*
|
|
325
|
+
* Design:
|
|
326
|
+
* evidence = 1.5 * freq + 0.5 * imp (freq weighs 3× importance)
|
|
327
|
+
* sigmoid(x) = 1 / (1 + exp(-k * (x - x0))) (x0=8 center, k=0.35 slope)
|
|
328
|
+
* unanchored × 0.6 (we're less confident which rule is affected)
|
|
329
|
+
* low evidence cap: if imp is null/undefined, cap evidence at 6 so the
|
|
330
|
+
* sigmoid cannot exceed ~0.36 — importance must be set for a strong score.
|
|
331
|
+
*
|
|
332
|
+
* Rough calibration:
|
|
333
|
+
* freq=3, imp=5, anchored → ~0.35
|
|
334
|
+
* freq=5, imp=7, anchored → ~0.66
|
|
335
|
+
* freq=10, imp=8, anchored → ~0.91
|
|
336
|
+
* freq=10, imp=8, unanchored → ~0.55
|
|
337
|
+
* freq=3, imp=null, anchored → ~0.26
|
|
338
|
+
*/
|
|
339
|
+
function computeConfidence(freq, imp, anchored) {
|
|
340
|
+
const f = Math.max(0, freq || 0);
|
|
341
|
+
const hasImp = imp !== null && imp !== undefined;
|
|
342
|
+
const i = hasImp ? imp : 0;
|
|
343
|
+
let evidence = 1.5 * f + 0.5 * i;
|
|
344
|
+
// Penalise missing importance: cap evidence so sigmoid can't exceed ~0.36
|
|
345
|
+
if (!hasImp) evidence = Math.min(evidence, 6);
|
|
346
|
+
const k = 0.35;
|
|
347
|
+
const x0 = 8;
|
|
348
|
+
const sig = 1 / (1 + Math.exp(-k * (evidence - x0)));
|
|
349
|
+
const multiplier = anchored ? 1.0 : 0.6;
|
|
350
|
+
return Math.max(0, Math.min(1, sig * multiplier));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function cmdProposeRules() {
|
|
354
|
+
const fpPath = path.join(MEMORY_DIR, "failure-patterns.md");
|
|
355
|
+
const proposalPath = path.join(MEMORY_DIR, "auto-rule-update.md");
|
|
356
|
+
if (!fs.existsSync(fpPath)) {
|
|
357
|
+
log(" ⏭️ failure-patterns.md not found");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const parsed = parseEntries(readFileSafe(fpPath));
|
|
361
|
+
const candidates = parsed.entries.filter(e => (e.meta.frequency || 0) >= 3);
|
|
362
|
+
if (candidates.length === 0) {
|
|
363
|
+
log(" ℹ️ No failure patterns with frequency >= 3 — nothing to propose");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const activeRulePaths = loadActiveRulePaths();
|
|
368
|
+
const rulesArr = Array.from(activeRulePaths);
|
|
369
|
+
const META_LINE_RE = /^\s*-\s*\*{0,2}\s*(?:frequency|count|last\s*seen|importance)\*{0,2}\s*[:=]/i;
|
|
370
|
+
const proposals = [];
|
|
371
|
+
for (const e of candidates) {
|
|
372
|
+
const id = e.heading.replace(/^##\s+/, "").trim();
|
|
373
|
+
const body = e.body.join("\n");
|
|
374
|
+
// Same glob-skip logic as isPreserved(): a rule's `paths: ["**/*"]`
|
|
375
|
+
// is a scope, not a concrete anchor. Only count matches against
|
|
376
|
+
// literal file paths so we don't falsely anchor a pattern just because
|
|
377
|
+
// its body mentions a glob string.
|
|
378
|
+
const matchedRule = rulesArr.find(p => p && !/[*?[\]]/.test(p) && body.includes(p));
|
|
379
|
+
const anchored = !!matchedRule;
|
|
380
|
+
const affectedRule = matchedRule || "(no matching active rule — consider new rule)";
|
|
381
|
+
const confidence = computeConfidence(e.meta.frequency, e.meta.importance, anchored);
|
|
382
|
+
// Build a meaningful summary by skipping metadata and blank lines,
|
|
383
|
+
// taking up to 3 content-bearing lines. Metadata-only summaries
|
|
384
|
+
// (which happened when an entry's first 3 lines were frequency/last seen/importance)
|
|
385
|
+
// are useless for the reviewer.
|
|
386
|
+
const summary = e.body
|
|
387
|
+
.filter(l => l.trim() && !META_LINE_RE.test(l))
|
|
388
|
+
.slice(0, 3)
|
|
389
|
+
.join(" ")
|
|
390
|
+
.trim() || "(no body content)";
|
|
391
|
+
proposals.push({
|
|
392
|
+
id,
|
|
393
|
+
frequency: e.meta.frequency,
|
|
394
|
+
importance: e.meta.importance,
|
|
395
|
+
anchored,
|
|
396
|
+
affectedRule,
|
|
397
|
+
confidence: confidence.toFixed(2),
|
|
398
|
+
summary,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const existing = readFileSafe(proposalPath, "# Auto Rule Update Proposals\n");
|
|
403
|
+
const block = `\n## Generated at ${new Date().toISOString()}\n\n${proposals.map(p => `### ${p.id}\n- **Affected rule:** \`${p.affectedRule}\`${p.anchored ? "" : " _(unanchored — confidence reduced)_"}\n- **Frequency:** ${p.frequency ?? "?"} · **Importance:** ${p.importance ?? "?"} · **Confidence:** ${p.confidence}\n- **Summary:** ${p.summary}\n- **Proposed change:** <review failure-patterns.md "${p.id}" and edit the affected rule accordingly>\n`).join("\n")}\n`;
|
|
404
|
+
|
|
405
|
+
writeFileSafe(proposalPath, existing + block);
|
|
406
|
+
log(` ✅ Appended ${proposals.length} proposal(s) to auto-rule-update.md`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function showHelp() {
|
|
410
|
+
log(`
|
|
411
|
+
Usage: npx claudeos-core memory <subcommand>
|
|
412
|
+
|
|
413
|
+
Subcommands:
|
|
414
|
+
compact Apply 4-stage compaction to decision-log.md and failure-patterns.md
|
|
415
|
+
score Recompute importance of failure-patterns.md entries (frequency × recency)
|
|
416
|
+
propose-rules Analyze failure patterns, append rule update suggestions to auto-rule-update.md
|
|
417
|
+
`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function cmdMemory(parsedArgs) {
|
|
421
|
+
const sub = process.argv.slice(3)[0];
|
|
422
|
+
switch (sub) {
|
|
423
|
+
case "compact": return cmdCompact();
|
|
424
|
+
case "score": return cmdScore();
|
|
425
|
+
case "propose-rules": return cmdProposeRules();
|
|
426
|
+
case undefined:
|
|
427
|
+
case "--help":
|
|
428
|
+
case "-h":
|
|
429
|
+
showHelp();
|
|
430
|
+
return;
|
|
431
|
+
default:
|
|
432
|
+
log(`Unknown memory subcommand: ${sub}`);
|
|
433
|
+
showHelp();
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
module.exports = { cmdMemory, computeConfidence };
|