@lh8ppl/claude-memory-kit 0.1.0 → 0.1.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/README.md +77 -0
- package/bin/cmk-auto-extract.mjs +62 -0
- package/bin/cmk-capture-prompt.mjs +65 -0
- package/bin/cmk-capture-turn.mjs +76 -0
- package/bin/cmk-compress-lazy.mjs +0 -0
- package/bin/cmk-compress-session.mjs +64 -0
- package/bin/cmk-daily-distill.mjs +0 -0
- package/bin/cmk-inject-context.mjs +69 -0
- package/bin/cmk-observe-edit.mjs +57 -0
- package/bin/cmk-weekly-curate.mjs +0 -0
- package/bin/cmk.mjs +11 -11
- package/package.json +10 -2
- package/src/audit-log.mjs +1 -0
- package/src/claude-md.mjs +212 -212
- package/src/compressor.mjs +18 -18
- package/src/doctor.mjs +21 -8
- package/src/frontmatter.mjs +73 -73
- package/src/index-rebuild.mjs +26 -4
- package/src/inject-context.mjs +150 -10
- package/src/install.mjs +49 -1
- package/src/mcp-server.mjs +17 -0
- package/src/memory-write.mjs +18 -5
- package/src/merge-facts.mjs +213 -213
- package/src/provenance.mjs +217 -217
- package/src/reindex.mjs +134 -134
- package/src/repair.mjs +26 -96
- package/src/sanitize.mjs +39 -0
- package/src/settings-hooks.mjs +186 -0
- package/src/spawn-bin.mjs +83 -0
- package/src/subcommands.mjs +144 -10
- package/src/write-fact.mjs +46 -3
- package/template/.gitignore.fragment +12 -12
- package/template/CLAUDE.md.template +53 -49
- package/template/docs/journey/journey-log.md.template +292 -292
- package/template/project/memory/INDEX.md.template +47 -47
- package/template/support/cron-jobs/daily-memory-distill.md +15 -15
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
- package/template/support/milvus-deploy/README.md +57 -57
- package/template/support/milvus-deploy/docker-compose.yml +66 -66
- package/template/support/scripts/auto-extract-memory.sh +102 -102
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
- package/template/support/scripts/refresh-distill-timestamp.py +35 -35
- package/template/support/scripts/register-crons.py +242 -242
- package/template/support/scripts/run-daily-distill.sh +67 -67
- package/template/support/scripts/run-weekly-curate.sh +58 -58
package/src/frontmatter.mjs
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
// Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
|
|
2
|
-
// every kit module uses to read and write per-fact frontmatter + scratchpad
|
|
3
|
-
// HTML-comment provenance (Layer 3+ will join).
|
|
4
|
-
//
|
|
5
|
-
// Per the Layer-2 review's I2 finding, the previous code had THREE different
|
|
6
|
-
// naive parsers across four modules (split-on-first-colon read; verbatim
|
|
7
|
-
// stringify write). Output and input weren't symmetric: booleans round-tripped
|
|
8
|
-
// as strings, arrays didn't round-trip at all, strings with `:` truncated on
|
|
9
|
-
// read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
|
|
10
|
-
// that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
|
|
11
|
-
//
|
|
12
|
-
// Public surface:
|
|
13
|
-
// parse(text) → {frontmatter, body, parseError?}
|
|
14
|
-
// - text: full file contents (with or without `---` markers)
|
|
15
|
-
// - returns frontmatter as a typed object (string/number/bool/array/etc.)
|
|
16
|
-
// - returns body as the markdown after the closing `---\n` (or empty)
|
|
17
|
-
// - if no frontmatter block: frontmatter is null, body is the full text
|
|
18
|
-
// - if YAML parse fails: frontmatter is null, parseError carries the message
|
|
19
|
-
//
|
|
20
|
-
// format({frontmatter, body}) → text
|
|
21
|
-
// - frontmatter: typed object; key order preserved per insertion
|
|
22
|
-
// - body: markdown; written verbatim after the closing `---\n`
|
|
23
|
-
// - if frontmatter is null/empty: just returns body
|
|
24
|
-
//
|
|
25
|
-
// js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
|
|
26
|
-
// ISO strings stay as strings). Output uses flowLevel: 1 — top-level
|
|
27
|
-
// mapping is block style; nested arrays render as `[a, b]` (matches the
|
|
28
|
-
// pre-refactor visual format).
|
|
29
|
-
|
|
30
|
-
import yaml from 'js-yaml';
|
|
31
|
-
|
|
32
|
-
const DUMP_OPTIONS = Object.freeze({
|
|
33
|
-
schema: yaml.CORE_SCHEMA,
|
|
34
|
-
flowLevel: 1,
|
|
35
|
-
lineWidth: -1, // no line wrapping
|
|
36
|
-
noRefs: true, // never emit YAML anchors / refs
|
|
37
|
-
sortKeys: false, // preserve insertion order
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const LOAD_OPTIONS = Object.freeze({
|
|
41
|
-
schema: yaml.CORE_SCHEMA,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
export function parse(text) {
|
|
45
|
-
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
-
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
-
if (!m) return { frontmatter: null, body: text };
|
|
48
|
-
let frontmatter;
|
|
49
|
-
try {
|
|
50
|
-
frontmatter = yaml.load(m[1], LOAD_OPTIONS);
|
|
51
|
-
} catch (e) {
|
|
52
|
-
return { frontmatter: null, body: text, parseError: e.message };
|
|
53
|
-
}
|
|
54
|
-
if (frontmatter === undefined || frontmatter === null) {
|
|
55
|
-
return { frontmatter: null, body: m[2] ?? '' };
|
|
56
|
-
}
|
|
57
|
-
if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
58
|
-
return {
|
|
59
|
-
frontmatter: null,
|
|
60
|
-
body: text,
|
|
61
|
-
parseError: 'frontmatter is not a mapping',
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
return { frontmatter, body: m[2] ?? '' };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function format({ frontmatter, body }) {
|
|
68
|
-
if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
|
|
69
|
-
return body ?? '';
|
|
70
|
-
}
|
|
71
|
-
const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
|
|
72
|
-
return `---\n${yamlBody}---\n${body ?? ''}`;
|
|
73
|
-
}
|
|
1
|
+
// Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
|
|
2
|
+
// every kit module uses to read and write per-fact frontmatter + scratchpad
|
|
3
|
+
// HTML-comment provenance (Layer 3+ will join).
|
|
4
|
+
//
|
|
5
|
+
// Per the Layer-2 review's I2 finding, the previous code had THREE different
|
|
6
|
+
// naive parsers across four modules (split-on-first-colon read; verbatim
|
|
7
|
+
// stringify write). Output and input weren't symmetric: booleans round-tripped
|
|
8
|
+
// as strings, arrays didn't round-trip at all, strings with `:` truncated on
|
|
9
|
+
// read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
|
|
10
|
+
// that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
|
|
11
|
+
//
|
|
12
|
+
// Public surface:
|
|
13
|
+
// parse(text) → {frontmatter, body, parseError?}
|
|
14
|
+
// - text: full file contents (with or without `---` markers)
|
|
15
|
+
// - returns frontmatter as a typed object (string/number/bool/array/etc.)
|
|
16
|
+
// - returns body as the markdown after the closing `---\n` (or empty)
|
|
17
|
+
// - if no frontmatter block: frontmatter is null, body is the full text
|
|
18
|
+
// - if YAML parse fails: frontmatter is null, parseError carries the message
|
|
19
|
+
//
|
|
20
|
+
// format({frontmatter, body}) → text
|
|
21
|
+
// - frontmatter: typed object; key order preserved per insertion
|
|
22
|
+
// - body: markdown; written verbatim after the closing `---\n`
|
|
23
|
+
// - if frontmatter is null/empty: just returns body
|
|
24
|
+
//
|
|
25
|
+
// js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
|
|
26
|
+
// ISO strings stay as strings). Output uses flowLevel: 1 — top-level
|
|
27
|
+
// mapping is block style; nested arrays render as `[a, b]` (matches the
|
|
28
|
+
// pre-refactor visual format).
|
|
29
|
+
|
|
30
|
+
import yaml from 'js-yaml';
|
|
31
|
+
|
|
32
|
+
const DUMP_OPTIONS = Object.freeze({
|
|
33
|
+
schema: yaml.CORE_SCHEMA,
|
|
34
|
+
flowLevel: 1,
|
|
35
|
+
lineWidth: -1, // no line wrapping
|
|
36
|
+
noRefs: true, // never emit YAML anchors / refs
|
|
37
|
+
sortKeys: false, // preserve insertion order
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const LOAD_OPTIONS = Object.freeze({
|
|
41
|
+
schema: yaml.CORE_SCHEMA,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function parse(text) {
|
|
45
|
+
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
+
if (!m) return { frontmatter: null, body: text };
|
|
48
|
+
let frontmatter;
|
|
49
|
+
try {
|
|
50
|
+
frontmatter = yaml.load(m[1], LOAD_OPTIONS);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { frontmatter: null, body: text, parseError: e.message };
|
|
53
|
+
}
|
|
54
|
+
if (frontmatter === undefined || frontmatter === null) {
|
|
55
|
+
return { frontmatter: null, body: m[2] ?? '' };
|
|
56
|
+
}
|
|
57
|
+
if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
58
|
+
return {
|
|
59
|
+
frontmatter: null,
|
|
60
|
+
body: text,
|
|
61
|
+
parseError: 'frontmatter is not a mapping',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { frontmatter, body: m[2] ?? '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function format({ frontmatter, body }) {
|
|
68
|
+
if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
|
|
69
|
+
return body ?? '';
|
|
70
|
+
}
|
|
71
|
+
const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
|
|
72
|
+
return `---\n${yamlBody}---\n${body ?? ''}`;
|
|
73
|
+
}
|
package/src/index-rebuild.mjs
CHANGED
|
@@ -367,14 +367,36 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
|
367
367
|
|
|
368
368
|
for (const source of sources) {
|
|
369
369
|
filesScanned++;
|
|
370
|
-
const content = readFileSync(source.path, 'utf8');
|
|
371
|
-
const sha1 = sha1OfContent(content);
|
|
372
370
|
const relPath = relativeSource(source.path, { projectRoot, userDir });
|
|
373
371
|
const existing = db
|
|
374
|
-
.prepare('SELECT sha1 FROM files WHERE path = ?')
|
|
372
|
+
.prepare('SELECT mtime, sha1 FROM files WHERE path = ?')
|
|
375
373
|
.get(relPath);
|
|
374
|
+
// Fast path: if the file's mtime matches the checkpoint, the content is
|
|
375
|
+
// unchanged — skip the read + sha1 entirely. This realizes design §9.2's
|
|
376
|
+
// "mtime+sha1 diff" intent (the prior impl sha1'd every file on every
|
|
377
|
+
// call) and is what makes reindexBoot cheap enough to run before every
|
|
378
|
+
// `cmk search` (finding #0) even as the memory corpus grows.
|
|
379
|
+
let mtime = null;
|
|
380
|
+
try {
|
|
381
|
+
mtime = Math.floor(statSync(source.path).mtimeMs);
|
|
382
|
+
} catch {
|
|
383
|
+
// stat failed (file vanished mid-walk); fall through to the read,
|
|
384
|
+
// which surfaces the error naturally.
|
|
385
|
+
}
|
|
386
|
+
if (existing && mtime !== null && existing.mtime === mtime) {
|
|
387
|
+
continue; // unchanged (mtime match — no read needed)
|
|
388
|
+
}
|
|
389
|
+
// Caveat: a content change that PRESERVES the old mtime (e.g. a restore
|
|
390
|
+
// tool that sets --times) is missed until the next real change or a
|
|
391
|
+
// `reindex --full`. Negligible in practice — the kit always writes a
|
|
392
|
+
// fresh mtime after the indexed one — and standard for mtime-based diffs.
|
|
393
|
+
//
|
|
394
|
+
// mtime differs (or no checkpoint) — confirm via sha1 so a mere mtime
|
|
395
|
+
// touch (content identical) doesn't trigger a needless reindex.
|
|
396
|
+
const content = readFileSync(source.path, 'utf8');
|
|
397
|
+
const sha1 = sha1OfContent(content);
|
|
376
398
|
if (existing && existing.sha1 === sha1) {
|
|
377
|
-
continue; // unchanged
|
|
399
|
+
continue; // content unchanged despite mtime touch
|
|
378
400
|
}
|
|
379
401
|
const n = txn(source);
|
|
380
402
|
filesReindexed++;
|
package/src/inject-context.mjs
CHANGED
|
@@ -84,17 +84,20 @@ const TIER_BUDGETS = Object.freeze({
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
// Per-tier reading plan. The hook reads the scratchpads allowed at that
|
|
87
|
-
// tier (per SCRATCHPADS_BY_TIER) plus
|
|
88
|
-
//
|
|
87
|
+
// tier (per SCRATCHPADS_BY_TIER) plus — for the project tier — the most
|
|
88
|
+
// recent rolling-window day file.
|
|
89
|
+
//
|
|
90
|
+
// INDEX.md is deliberately NOT in the snapshot (#R, 2026-05-30). It is a
|
|
91
|
+
// pointer/reference doc that self-declares "NOT auto-loaded at session
|
|
92
|
+
// start" in its own template body — injecting it both violated that
|
|
93
|
+
// contract and pushed ~2 KB of reference prose into Claude's context,
|
|
94
|
+
// crowding out real facts. It stays on disk for lookup via `cmk search` /
|
|
95
|
+
// the granular archive; it is not session-start content.
|
|
89
96
|
function plannedFilesForTier(tier, tierRoot) {
|
|
90
97
|
const files = [];
|
|
91
98
|
for (const name of SCRATCHPADS_BY_TIER[tier]) {
|
|
92
99
|
files.push(join(tierRoot, name));
|
|
93
100
|
}
|
|
94
|
-
// INDEX: P/L use memory/INDEX.md; U uses fragments/INDEX.md (per
|
|
95
|
-
// resolveFactDir asymmetry in tier-paths.mjs).
|
|
96
|
-
const indexDir = tier === 'U' ? 'fragments' : 'memory';
|
|
97
|
-
files.push(join(tierRoot, indexDir, 'INDEX.md'));
|
|
98
101
|
if (tier === 'P') {
|
|
99
102
|
const sessionsDir = join(tierRoot, 'sessions');
|
|
100
103
|
const latest = latestDaySession(sessionsDir);
|
|
@@ -138,10 +141,145 @@ function tierDirExists(tier, tierRoot) {
|
|
|
138
141
|
return existsSync(tierRoot) && statSync(tierRoot).isDirectory();
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
// The all-zero sha1 is the kit's template-seed sentinel: every scaffolded
|
|
145
|
+
// placeholder bullet (in machine-paths/overrides/SOUL/USER/HABITS/LESSONS)
|
|
146
|
+
// carries `sha1: 0000…0000` + `at: 2020-01-01T…`. A real captured fact
|
|
147
|
+
// always has a real content sha1. We use this to distinguish "scaffolding
|
|
148
|
+
// the user never replaced" from "a fact worth injecting".
|
|
149
|
+
const SEED_SHA1_RE = /sha1:\s*0{40}/;
|
|
150
|
+
|
|
151
|
+
// All HTML-comment handling below uses STRING SCANNING (indexOf/startsWith),
|
|
152
|
+
// never a regex tag-filter. Regex-based HTML-comment stripping is fragile by
|
|
153
|
+
// nature (it can't see newlines, leaves partial `<!--`, etc. — flagged by
|
|
154
|
+
// CodeQL's js/bad-tag-filter). String scanning is both more robust and not a
|
|
155
|
+
// tag-filter, so it sidesteps that whole class.
|
|
156
|
+
|
|
157
|
+
// True if `line`, ignoring surrounding whitespace, is exactly one self-
|
|
158
|
+
// contained HTML comment (`<!-- … -->`) — e.g. a per-bullet provenance line.
|
|
159
|
+
function isCommentOnlyLine(line) {
|
|
160
|
+
if (typeof line !== 'string') return false;
|
|
161
|
+
const t = line.trim();
|
|
162
|
+
return t.startsWith('<!--') && t.endsWith('-->') && t.length >= 7;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Remove every self-contained `<!-- … -->` span WITHIN a single line, by
|
|
166
|
+
// scanning for delimiter pairs. An unterminated `<!--` (no `-->` on this
|
|
167
|
+
// line) is left in place for the multi-line state machine to handle.
|
|
168
|
+
function stripInlineComments(line) {
|
|
169
|
+
let out = '';
|
|
170
|
+
let i = 0;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const open = line.indexOf('<!--', i);
|
|
173
|
+
if (open === -1) return out + line.slice(i);
|
|
174
|
+
const close = line.indexOf('-->', open + 4);
|
|
175
|
+
if (close === -1) return out + line.slice(i); // unterminated; leave it
|
|
176
|
+
out += line.slice(i, open);
|
|
177
|
+
i = close + 3;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Is `bulletLine` a placeholder/seed bullet that should NOT be injected?
|
|
182
|
+
// Primary signal: a following provenance comment carrying the all-zero seed
|
|
183
|
+
// sha1 (every scaffolded template bullet has it; a real captured fact never
|
|
184
|
+
// does). Secondary: the `(example)` marker — but ONLY in the template's
|
|
185
|
+
// exact `(P-XXXXXXXX) (example) …` shape (right after the citation id), so a
|
|
186
|
+
// real fact whose text merely mentions "(example)" is not mis-dropped.
|
|
187
|
+
function isSeedBullet(bulletLine, nextLine) {
|
|
188
|
+
if (/^\s*-\s+\([PUL]-[A-Za-z0-9]{8}\)\s+\(example\)/.test(bulletLine)) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
const prov = isCommentOnlyLine(nextLine) ? nextLine : '';
|
|
192
|
+
return SEED_SHA1_RE.test(prov);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Remove HTML comments robustly, including the kit templates' multi-line
|
|
196
|
+
// format-explanation headers that ILLUSTRATIVELY embed a single-line
|
|
197
|
+
// `<!-- source… -->` example inside the outer `<!-- … -->` block (a naive
|
|
198
|
+
// "first <!-- to first -->" pass closes on that inner `-->` and orphans the
|
|
199
|
+
// tail). We strip inline comments first (killing the nested one) and only
|
|
200
|
+
// then walk the now-cleanly-delimited multi-line blocks. All string-scan.
|
|
201
|
+
function stripHtmlComments(text) {
|
|
202
|
+
// Pass 1 — remove every self-contained `<!-- … -->` on a single line.
|
|
203
|
+
const lines = text.split('\n').map(stripInlineComments);
|
|
204
|
+
// Pass 2 — remove multi-line blocks (each now free of any inner `-->`).
|
|
205
|
+
const out = [];
|
|
206
|
+
let inBlock = false;
|
|
207
|
+
for (let line of lines) {
|
|
208
|
+
if (inBlock) {
|
|
209
|
+
const close = line.indexOf('-->');
|
|
210
|
+
if (close === -1) continue; // still inside the block; drop the line
|
|
211
|
+
inBlock = false;
|
|
212
|
+
line = line.slice(close + 3);
|
|
213
|
+
}
|
|
214
|
+
const open = line.indexOf('<!--');
|
|
215
|
+
if (open !== -1) {
|
|
216
|
+
inBlock = true;
|
|
217
|
+
line = line.slice(0, open);
|
|
218
|
+
}
|
|
219
|
+
if (line.trim() !== '' || out.length === 0 || out[out.length - 1] !== '') {
|
|
220
|
+
out.push(line.replace(/[ \t]+$/, ''));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out.join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Clean a scratchpad body for INJECTION (not for on-disk storage — the
|
|
227
|
+
// files keep their human-editing headers). Self-test finding #R: the raw
|
|
228
|
+
// bodies are ~70% template-comment noise + placeholder seed bullets that
|
|
229
|
+
// bury (and crowd out) the real captured facts, so the model concludes
|
|
230
|
+
// "no real facts populated yet". This strips:
|
|
231
|
+
// 1. placeholder seed bullets (all-zero sha1 / `(example)`) + their
|
|
232
|
+
// provenance comment line, and
|
|
233
|
+
// 2. ALL remaining `<!-- -->` comments (multi-line format-explanation
|
|
234
|
+
// headers AND per-bullet provenance — the fact text + its `(P-…)`
|
|
235
|
+
// citation id carry everything the model needs to read & cite).
|
|
236
|
+
// Whitespace is normalized so stripped regions don't leave holes.
|
|
237
|
+
//
|
|
238
|
+
// Known limitation (rare): a captured fact whose TEXT contains a literal
|
|
239
|
+
// `<!--`/`-->` (e.g. a note about HTML/templating) has that fragment
|
|
240
|
+
// stripped from the INJECTED view. The on-disk fact and the search index
|
|
241
|
+
// are unaffected — only the session-start snapshot loses the literal
|
|
242
|
+
// comment markers. Accepted as a rare edge vs. the cost of distinguishing
|
|
243
|
+
// real comments from comment-shaped fact text.
|
|
244
|
+
function cleanScratchpadBody(body) {
|
|
245
|
+
// Normalize CRLF so user-edited (Windows) scratchpads don't leave stray
|
|
246
|
+
// \r after comment/seed stripping.
|
|
247
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
248
|
+
const kept = [];
|
|
249
|
+
for (let i = 0; i < lines.length; i++) {
|
|
250
|
+
const line = lines[i];
|
|
251
|
+
if (
|
|
252
|
+
/^\s*-\s/.test(line) &&
|
|
253
|
+
ID_TOKEN_RE.test(line) &&
|
|
254
|
+
isSeedBullet(line, lines[i + 1])
|
|
255
|
+
) {
|
|
256
|
+
if (isCommentOnlyLine(lines[i + 1])) i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
kept.push(line);
|
|
260
|
+
}
|
|
261
|
+
// Step 2 — strip all remaining comments (format headers + real-bullet
|
|
262
|
+
// provenance), then normalize whitespace.
|
|
263
|
+
return stripHtmlComments(kept.join('\n'))
|
|
264
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
265
|
+
.replace(/^\n+|\n+$/g, '');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// After cleaning, does a body carry any real content — i.e. a non-blank
|
|
269
|
+
// line that isn't a markdown heading? A body of only headings (every
|
|
270
|
+
// bullet was a stripped seed) is pure scaffolding and must NOT contribute
|
|
271
|
+
// a tier block (otherwise the model sees an empty "## …" skeleton).
|
|
272
|
+
function hasRealContent(cleaned) {
|
|
273
|
+
return cleaned
|
|
274
|
+
.split('\n')
|
|
275
|
+
.some((l) => l.trim() !== '' && !/^#{1,6}\s/.test(l));
|
|
276
|
+
}
|
|
277
|
+
|
|
141
278
|
// Read the snapshot-eligible content for one tier as a single string. If
|
|
142
|
-
// no tier files exist (or the tier dir itself is absent), returns ''.
|
|
143
|
-
//
|
|
144
|
-
//
|
|
279
|
+
// no tier files exist (or the tier dir itself is absent), returns ''. Each
|
|
280
|
+
// file body is cleaned for injection (see cleanScratchpadBody); files that
|
|
281
|
+
// reduce to scaffolding-only contribute nothing, and a tier whose every
|
|
282
|
+
// file is scaffolding-only is excluded entirely (no header, no skeleton).
|
|
145
283
|
function readTierBlock(tier, tierRoot) {
|
|
146
284
|
if (!tierDirExists(tier, tierRoot)) return '';
|
|
147
285
|
const sections = [];
|
|
@@ -154,7 +292,9 @@ function readTierBlock(tier, tierRoot) {
|
|
|
154
292
|
continue;
|
|
155
293
|
}
|
|
156
294
|
if (body.trim() === '') continue;
|
|
157
|
-
|
|
295
|
+
const cleaned = cleanScratchpadBody(body);
|
|
296
|
+
if (!hasRealContent(cleaned)) continue;
|
|
297
|
+
sections.push(cleaned);
|
|
158
298
|
}
|
|
159
299
|
if (sections.length === 0) return '';
|
|
160
300
|
const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
|
package/src/install.mjs
CHANGED
|
@@ -43,6 +43,8 @@ import { homedir } from 'node:os';
|
|
|
43
43
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
45
|
import { injectClaudeMdBlock } from './claude-md.mjs';
|
|
46
|
+
import { writeKitHooks } from './settings-hooks.mjs';
|
|
47
|
+
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
46
48
|
|
|
47
49
|
const __filename = fileURLToPath(import.meta.url);
|
|
48
50
|
const CLI_SRC_DIR = dirname(__filename);
|
|
@@ -295,7 +297,53 @@ export async function install(options = {}) {
|
|
|
295
297
|
});
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
|
|
300
|
+
// Hook wiring — Task 49. This is what makes `npm install -g
|
|
301
|
+
// @lh8ppl/claude-memory-kit` + `cmk install` a COMPLETE entry point
|
|
302
|
+
// (no separate `/plugin install` step needed). Writes the npm-route
|
|
303
|
+
// hooks block (PATH-resolved bare bin names, shell form) into
|
|
304
|
+
// <projectRoot>/.claude/settings.json via the shared writeKitHooks
|
|
305
|
+
// boundary — same boundary `cmk repair --hooks` uses, so install and
|
|
306
|
+
// repair never drift. Idempotent: a re-run with already-canonical
|
|
307
|
+
// hooks is a no-op. Opt out with {noHooks:true} (CLI: --no-hooks) for
|
|
308
|
+
// scaffold-only installs.
|
|
309
|
+
let hooks = { action: 'skipped', path: join(projectRoot, '.claude', 'settings.json') };
|
|
310
|
+
if (!options.noHooks) {
|
|
311
|
+
const settingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
312
|
+
const r = writeKitHooks(settingsPath);
|
|
313
|
+
if (r.error) {
|
|
314
|
+
errors.push({ path: settingsPath, error: r.error });
|
|
315
|
+
hooks = { action: 'error', path: settingsPath, error: r.error };
|
|
316
|
+
} else {
|
|
317
|
+
hooks = {
|
|
318
|
+
action: r.changed ? 'wired' : 'unchanged',
|
|
319
|
+
path: settingsPath,
|
|
320
|
+
events: r.events,
|
|
321
|
+
};
|
|
322
|
+
// Door-4 audit entry — install wires user-visible Claude Code
|
|
323
|
+
// config; a "cmk install changed my settings.json" report needs a
|
|
324
|
+
// trail. Emitted ONLY when something actually changed: a no-op
|
|
325
|
+
// re-install has nothing to audit, and emitting on no-op would make
|
|
326
|
+
// the append-only audit.log grow on every run, breaking install's
|
|
327
|
+
// idempotency guarantee (re-run = byte-identical project tree).
|
|
328
|
+
// Best-effort: never block install on an audit-log failure.
|
|
329
|
+
if (r.changed) {
|
|
330
|
+
try {
|
|
331
|
+
appendAuditEntry(join(projectRoot, 'context'), {
|
|
332
|
+
ts: nowIso(),
|
|
333
|
+
action: 'install',
|
|
334
|
+
tier: 'P',
|
|
335
|
+
id: 'P-NSTLHKWR', // synthetic stable id for install-hooks events (base32 alphabet)
|
|
336
|
+
reasonCode: REASON_CODES.INSTALL_HOOKS_WIRED,
|
|
337
|
+
extra: { settingsPath, events: r.events },
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
// best-effort
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, errors };
|
|
299
347
|
}
|
|
300
348
|
|
|
301
349
|
/**
|
package/src/mcp-server.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
34
34
|
import { z } from 'zod';
|
|
35
35
|
import { resolve as resolvePath, isAbsolute } from 'node:path';
|
|
36
36
|
import { openIndexDb } from './index-db.mjs';
|
|
37
|
+
import { reindexBoot } from './index-rebuild.mjs';
|
|
37
38
|
import { search, SEARCH_MODES } from './search.mjs';
|
|
38
39
|
import { memoryWrite } from './memory-write.mjs';
|
|
39
40
|
import { ID_PATTERN, resolveTierRoot } from './tier-paths.mjs';
|
|
@@ -451,6 +452,22 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
|
|
|
451
452
|
*/
|
|
452
453
|
export async function runMcpServer({ projectRoot, userDir, db: dbOverride, semanticBackend } = {}) {
|
|
453
454
|
const db = dbOverride ?? openIndexDb({ projectRoot });
|
|
455
|
+
// Refresh the index at server startup so mk_search sees facts already on
|
|
456
|
+
// disk — same fresh-install gap as `cmk search` (self-test finding #0):
|
|
457
|
+
// nothing reindexes for a just-installed project, so without this the
|
|
458
|
+
// model's first mk_search returns empty for facts sitting in the
|
|
459
|
+
// scratchpads. Incremental (mtime/sha1 diff) + best-effort; in-session
|
|
460
|
+
// freshness for facts written AFTER startup is the runtime watcher's job
|
|
461
|
+
// (future). The in-process buildMcpServer tests bypass this path.
|
|
462
|
+
if (projectRoot) {
|
|
463
|
+
try {
|
|
464
|
+
reindexBoot({ projectRoot, userDir, db });
|
|
465
|
+
} catch (err) {
|
|
466
|
+
process.stderr.write(
|
|
467
|
+
`cmk-mcp-server: startup index refresh failed: ${err?.message ?? err}\n`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
454
471
|
const server = buildMcpServer({ projectRoot, userDir, db, semanticBackend });
|
|
455
472
|
const transport = new StdioServerTransport();
|
|
456
473
|
|
package/src/memory-write.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { appendScratchpadBullet } from './scratchpad.mjs';
|
|
|
57
57
|
import { parseBulletProvenance } from './provenance.mjs';
|
|
58
58
|
import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
|
|
59
59
|
import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
|
|
60
|
+
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
60
61
|
|
|
61
62
|
const VALID_ACTIONS = new Set(['add', 'replace', 'remove']);
|
|
62
63
|
|
|
@@ -252,8 +253,20 @@ function doAdd(opts) {
|
|
|
252
253
|
if (errors.length > 0) {
|
|
253
254
|
return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
|
|
254
255
|
}
|
|
256
|
+
// Privacy (write-path fix #1): abstract home-dir paths to `~` for
|
|
257
|
+
// committed/shared tiers (P/U) BEFORE the bullet is screened, conflict-
|
|
258
|
+
// checked, dedup-keyed, and written — so a captured fact never ships the
|
|
259
|
+
// local username and stays portable. Local tier (L) keeps machine paths
|
|
260
|
+
// verbatim (its purpose). Everything downstream uses `addOpts`.
|
|
261
|
+
const sanitizedText =
|
|
262
|
+
opts.tier === 'P' || opts.tier === 'U'
|
|
263
|
+
? sanitizeHomePaths(opts.text)
|
|
264
|
+
: opts.text;
|
|
265
|
+
const addOpts =
|
|
266
|
+
sanitizedText === opts.text ? opts : { ...opts, text: sanitizedText };
|
|
267
|
+
|
|
255
268
|
const poisonResult = runPoisonGuard({
|
|
256
|
-
text:
|
|
269
|
+
text: addOpts.text,
|
|
257
270
|
projectRoot: opts.projectRoot,
|
|
258
271
|
source: opts.source,
|
|
259
272
|
sessionId: opts.sessionId,
|
|
@@ -276,7 +289,7 @@ function doAdd(opts) {
|
|
|
276
289
|
userDir: opts.userDir,
|
|
277
290
|
});
|
|
278
291
|
const conflict = detectConflicts({
|
|
279
|
-
newText:
|
|
292
|
+
newText: addOpts.text,
|
|
280
293
|
newTrust,
|
|
281
294
|
scratchpadPath,
|
|
282
295
|
sectionTitle: opts.section,
|
|
@@ -296,14 +309,14 @@ function doAdd(opts) {
|
|
|
296
309
|
// appendScratchpadBullet would have used, then route to the queue.
|
|
297
310
|
// (Task 25b fix: generateId is positional `(tier, text)`, not
|
|
298
311
|
// named-args — Task 25 originally called it as an object.)
|
|
299
|
-
const proposedId = generateId(
|
|
312
|
+
const proposedId = generateId(addOpts.tier, addOpts.text);
|
|
300
313
|
const ts = opts.now ?? nowIso();
|
|
301
314
|
return writeConflictEntry({
|
|
302
315
|
tier: opts.tier,
|
|
303
316
|
projectRoot: opts.projectRoot,
|
|
304
317
|
userDir: opts.userDir,
|
|
305
318
|
newId: proposedId,
|
|
306
|
-
newText:
|
|
319
|
+
newText: addOpts.text,
|
|
307
320
|
newTrust,
|
|
308
321
|
existingId: conflict.existingId,
|
|
309
322
|
existingText: conflict.existingText,
|
|
@@ -313,7 +326,7 @@ function doAdd(opts) {
|
|
|
313
326
|
detectedAt: ts,
|
|
314
327
|
});
|
|
315
328
|
}
|
|
316
|
-
return appendBulletGuarded(
|
|
329
|
+
return appendBulletGuarded(addOpts);
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
function appendBulletGuarded(opts) {
|