@lh8ppl/claude-memory-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// SQLite index rebuild + runtime file-watcher (Task 29, T-025).
|
|
2
|
+
//
|
|
3
|
+
// Composes on top of:
|
|
4
|
+
// - index-db.mjs (Task 28) — schema + openIndexDb
|
|
5
|
+
// - provenance.mjs (Task 13) — readBullet (parses bullet+comment pairs)
|
|
6
|
+
// - frontmatter.mjs (Task 7) — parse (YAML frontmatter for fact files)
|
|
7
|
+
// - tier-paths.mjs — resolveTierRoot, resolveFactDir, ID_PATTERN
|
|
8
|
+
//
|
|
9
|
+
// Public surface:
|
|
10
|
+
// listObservationSources({projectRoot, userDir})
|
|
11
|
+
// Returns absolute paths of every markdown file the kit treats as a
|
|
12
|
+
// source of observations: <tier>/MEMORY.md + <tier>/memory/*.md
|
|
13
|
+
// across the P / L / U tiers. Caller-skipped: today-{date}.md
|
|
14
|
+
// compression archives (Haiku output isn't kit-canonical bullet+comment
|
|
15
|
+
// shape — see design §16.x as a v0.1.x candidate to index session
|
|
16
|
+
// compressions as observations once Haiku's output schema is pinned).
|
|
17
|
+
//
|
|
18
|
+
// reindexBoot({projectRoot, userDir, db})
|
|
19
|
+
// Walk every source file. For each: compute sha1 of file content;
|
|
20
|
+
// compare against the `files` checkpoint table. Skip unchanged.
|
|
21
|
+
// Reindex changed: DELETE all rows where source_file = path, parse
|
|
22
|
+
// observations, INSERT, UPSERT files row. Atomic per-file via SQLite
|
|
23
|
+
// transaction so a partial reindex never leaves a half-written file.
|
|
24
|
+
//
|
|
25
|
+
// reindexFull({projectRoot, userDir, db})
|
|
26
|
+
// DROP observations / observations_fts / files tables; re-apply
|
|
27
|
+
// INDEX_DB_SCHEMA; walk + reindex every source unconditionally.
|
|
28
|
+
// Faster than DELETE FROM observations for large indexes because
|
|
29
|
+
// the FTS5 delete trigger doesn't fire per row.
|
|
30
|
+
//
|
|
31
|
+
// startRuntimeWatcher({projectRoot, userDir, db, debounceMs})
|
|
32
|
+
// chokidar watcher over the same source paths as listObservationSources.
|
|
33
|
+
// Debounced 500ms by default per design §9.2. Returns {close()} so the
|
|
34
|
+
// caller can shut down cleanly (tests, hook handlers).
|
|
35
|
+
//
|
|
36
|
+
// Design §9.2 reindex strategy:
|
|
37
|
+
// - Boot: walk + diff mtime+sha1 vs files table → reindex changed only
|
|
38
|
+
// - Runtime: chokidar 500ms debounce → reindex on FS event
|
|
39
|
+
// - Recovery: drop DB + rebuild from markdown
|
|
40
|
+
//
|
|
41
|
+
// Per CLAUDE.md "Shared modules" rule, this module imports from the
|
|
42
|
+
// established sources of truth and does NOT re-implement bullet/frontmatter
|
|
43
|
+
// parsing or path resolution.
|
|
44
|
+
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
47
|
+
import { basename, join, relative } from 'node:path';
|
|
48
|
+
import chokidar from 'chokidar';
|
|
49
|
+
import { INDEX_DB_SCHEMA } from './index-db.mjs';
|
|
50
|
+
import { readBullet, parseBulletProvenance } from './provenance.mjs';
|
|
51
|
+
import { parse as parseFrontmatter } from './frontmatter.mjs';
|
|
52
|
+
import {
|
|
53
|
+
VALID_TIERS,
|
|
54
|
+
resolveTierRoot,
|
|
55
|
+
resolveFactDir,
|
|
56
|
+
ID_PATTERN,
|
|
57
|
+
} from './tier-paths.mjs';
|
|
58
|
+
|
|
59
|
+
// --- File listing -----------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enumerate the observation-source files across all three tiers.
|
|
63
|
+
* Returns objects with absolute path + the tier it belongs to + the
|
|
64
|
+
* file kind ('scratchpad' or 'fact') so callers don't have to
|
|
65
|
+
* re-derive the parsing strategy.
|
|
66
|
+
*/
|
|
67
|
+
export function listObservationSources({ projectRoot, userDir }) {
|
|
68
|
+
const sources = [];
|
|
69
|
+
for (const tier of ['P', 'L', 'U']) {
|
|
70
|
+
const root = resolveTierRoot({ tier, projectRoot, userDir });
|
|
71
|
+
if (!existsSync(root)) continue;
|
|
72
|
+
// Scratchpad: <tier>/MEMORY.md
|
|
73
|
+
const scratchpad = join(root, 'MEMORY.md');
|
|
74
|
+
if (existsSync(scratchpad)) {
|
|
75
|
+
sources.push({ path: scratchpad, tier, kind: 'scratchpad' });
|
|
76
|
+
}
|
|
77
|
+
// Granular fact files: <tier>/memory/*.md (excluding INDEX.md)
|
|
78
|
+
const factDir = resolveFactDir(tier, root);
|
|
79
|
+
if (existsSync(factDir)) {
|
|
80
|
+
for (const entry of readdirSync(factDir, { withFileTypes: true })) {
|
|
81
|
+
if (!entry.isFile()) continue;
|
|
82
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
83
|
+
if (entry.name === 'INDEX.md') continue;
|
|
84
|
+
sources.push({
|
|
85
|
+
path: join(factDir, entry.name),
|
|
86
|
+
tier,
|
|
87
|
+
kind: 'fact',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return sources;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Helpers ----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function sha1OfContent(content) {
|
|
98
|
+
return createHash('sha1').update(content, 'utf8').digest('hex');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isoToEpochMs(iso) {
|
|
102
|
+
const t = Date.parse(iso);
|
|
103
|
+
return Number.isFinite(t) ? t : 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function relativeSource(absPath, { projectRoot, userDir }) {
|
|
107
|
+
// Sibling-prefix guard: a naive startsWith() check would misclassify
|
|
108
|
+
// "/foo-other/x.md" as inside "/foo". The path-separator suffix
|
|
109
|
+
// ensures we only match true descendants. Surfaced as Important
|
|
110
|
+
// finding I2 by the Task 29 code-review.
|
|
111
|
+
const sep = process.platform === 'win32' ? /[\\/]/ : '/';
|
|
112
|
+
function isInside(parent, child) {
|
|
113
|
+
if (!parent) return false;
|
|
114
|
+
if (!child.startsWith(parent)) return false;
|
|
115
|
+
if (child.length === parent.length) return false;
|
|
116
|
+
const next = child.charAt(parent.length);
|
|
117
|
+
return process.platform === 'win32'
|
|
118
|
+
? next === '\\' || next === '/'
|
|
119
|
+
: next === '/';
|
|
120
|
+
}
|
|
121
|
+
if (isInside(userDir, absPath)) {
|
|
122
|
+
return relative(userDir, absPath).replaceAll('\\', '/');
|
|
123
|
+
}
|
|
124
|
+
return relative(projectRoot, absPath).replaceAll('\\', '/');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Parsing ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse a scratchpad MEMORY.md into observations.
|
|
131
|
+
*
|
|
132
|
+
* Walks line-by-line tracking the most recent h2 heading. For each
|
|
133
|
+
* bullet+comment pair, calls readBullet() to extract id/text/provenance.
|
|
134
|
+
* Returns one row per bullet conforming to the observations schema.
|
|
135
|
+
*
|
|
136
|
+
* Tolerant: bullets without a following provenance comment are skipped
|
|
137
|
+
* (the kit's writeBullet always emits both). Bullets whose readBullet()
|
|
138
|
+
* returns null (malformed id, missing required provenance fields) are
|
|
139
|
+
* skipped — the broader markdown file still indexes its valid bullets.
|
|
140
|
+
*/
|
|
141
|
+
export function parseObservationsFromScratchpad({
|
|
142
|
+
path,
|
|
143
|
+
content,
|
|
144
|
+
tier,
|
|
145
|
+
projectRoot,
|
|
146
|
+
userDir,
|
|
147
|
+
}) {
|
|
148
|
+
const lines = content.split('\n');
|
|
149
|
+
const sha1 = sha1OfContent(content);
|
|
150
|
+
const source_file = relativeSource(path, { projectRoot, userDir });
|
|
151
|
+
const baseName = basename(path);
|
|
152
|
+
|
|
153
|
+
const observations = [];
|
|
154
|
+
let currentHeading = null;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
const line = lines[i];
|
|
158
|
+
const headingMatch = /^##\s+(.+)$/.exec(line);
|
|
159
|
+
if (headingMatch) {
|
|
160
|
+
currentHeading = headingMatch[1].trim();
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// Try to parse this line as a bullet, with line i+1 as the
|
|
164
|
+
// provenance comment.
|
|
165
|
+
const next = lines[i + 1] ?? '';
|
|
166
|
+
const bullet = readBullet({ bulletLine: line, commentLine: next });
|
|
167
|
+
if (!bullet) continue;
|
|
168
|
+
const { id, text, provenance } = bullet;
|
|
169
|
+
const heading_path = currentHeading
|
|
170
|
+
? `${baseName} > ${currentHeading}`
|
|
171
|
+
: baseName;
|
|
172
|
+
observations.push({
|
|
173
|
+
id,
|
|
174
|
+
tier,
|
|
175
|
+
source_file,
|
|
176
|
+
source_line: i + 1,
|
|
177
|
+
source_sha1: sha1,
|
|
178
|
+
heading_path,
|
|
179
|
+
body: text,
|
|
180
|
+
write_source: provenance.write,
|
|
181
|
+
trust: provenance.trust,
|
|
182
|
+
created_at: isoToEpochMs(provenance.at),
|
|
183
|
+
superseded_by: provenance.superseded_by ?? null,
|
|
184
|
+
deleted_at: null, // scratchpads don't tombstone in place
|
|
185
|
+
});
|
|
186
|
+
// Skip the comment line so we don't try to parse it as a bullet.
|
|
187
|
+
i++;
|
|
188
|
+
}
|
|
189
|
+
return { observations, sha1 };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse a granular fact file into a single observation.
|
|
194
|
+
*
|
|
195
|
+
* Per-fact files have YAML frontmatter (id, type, title, source, sha1,
|
|
196
|
+
* write_source, trust, at, optional deleted_at + superseded_by) and a
|
|
197
|
+
* markdown body. The whole file = one observation row.
|
|
198
|
+
*/
|
|
199
|
+
export function parseObservationsFromFactFile({
|
|
200
|
+
path,
|
|
201
|
+
content,
|
|
202
|
+
tier,
|
|
203
|
+
projectRoot,
|
|
204
|
+
userDir,
|
|
205
|
+
}) {
|
|
206
|
+
const sha1 = sha1OfContent(content);
|
|
207
|
+
const source_file = relativeSource(path, { projectRoot, userDir });
|
|
208
|
+
const baseName = basename(path);
|
|
209
|
+
const { frontmatter, body, parseError } = parseFrontmatter(content);
|
|
210
|
+
if (!frontmatter || parseError) {
|
|
211
|
+
return { observations: [], sha1, skipped: parseError ?? 'no frontmatter' };
|
|
212
|
+
}
|
|
213
|
+
if (!frontmatter.id || !ID_PATTERN.test(frontmatter.id)) {
|
|
214
|
+
return { observations: [], sha1, skipped: 'invalid or missing id' };
|
|
215
|
+
}
|
|
216
|
+
// Kit's writeFact (see packages/cli/src/write-fact.mjs:96-115) writes
|
|
217
|
+
// these field names: `created_at` (not `at`), `source_file` (not
|
|
218
|
+
// `source`), `source_sha1` (not `sha1`). An earlier draft of this
|
|
219
|
+
// parser used the shorter `at`/`source`/`sha1` names — surfaced by
|
|
220
|
+
// Task 29's code-review-excellence pass as a separately-correct-
|
|
221
|
+
// jointly-broken composition gap that would have made reindex a
|
|
222
|
+
// no-op for every kit-produced fact file. The fix here reads the
|
|
223
|
+
// canonical writer-emitted names; the test helper (seedFactFile)
|
|
224
|
+
// now uses writeFact() directly so this kind of drift surfaces at
|
|
225
|
+
// TDD time. Per CLAUDE.md "Integration-test coverage for cross-
|
|
226
|
+
// module flows".
|
|
227
|
+
if (!frontmatter.write_source || !frontmatter.trust || !frontmatter.created_at) {
|
|
228
|
+
return {
|
|
229
|
+
observations: [],
|
|
230
|
+
sha1,
|
|
231
|
+
skipped: 'missing write_source / trust / created_at',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
// The kit's "type" field becomes the heading_path qualifier.
|
|
235
|
+
const heading_path = frontmatter.type
|
|
236
|
+
? `${baseName} > ${frontmatter.type}`
|
|
237
|
+
: baseName;
|
|
238
|
+
// Important: the observations table's `source_file` field means
|
|
239
|
+
// "on-disk location of the markdown that holds this observation"
|
|
240
|
+
// (e.g., `memory/<fact>.md` for per-fact files, `MEMORY.md` for
|
|
241
|
+
// scratchpad bullets). It is NOT the frontmatter's `source_file`
|
|
242
|
+
// field, which is provenance — "where did this fact ORIGINATE
|
|
243
|
+
// from" (e.g., a MEMORY.md bullet that was promoted to a fact via
|
|
244
|
+
// `cmk promote`). The two concepts share a field name but have
|
|
245
|
+
// different semantics. The DELETE-then-INSERT pattern in
|
|
246
|
+
// replaceObservationsForFile keys on the on-disk location, so the
|
|
247
|
+
// index must use that interpretation. The provenance lineage is
|
|
248
|
+
// retrievable by reading the fact file's frontmatter when needed.
|
|
249
|
+
// source_sha1 is similarly the sha1 of the file being indexed —
|
|
250
|
+
// used as the diff key in reindexBoot's mtime+sha1 checkpoint.
|
|
251
|
+
const observation = {
|
|
252
|
+
id: frontmatter.id,
|
|
253
|
+
tier,
|
|
254
|
+
source_file,
|
|
255
|
+
source_line: 1, // frontmatter starts at line 1
|
|
256
|
+
source_sha1: sha1,
|
|
257
|
+
heading_path,
|
|
258
|
+
body: (body ?? '').trim() || (frontmatter.title ?? ''),
|
|
259
|
+
write_source: frontmatter.write_source,
|
|
260
|
+
trust: frontmatter.trust,
|
|
261
|
+
created_at: isoToEpochMs(frontmatter.created_at),
|
|
262
|
+
superseded_by: frontmatter.superseded_by ?? null,
|
|
263
|
+
deleted_at: frontmatter.deleted_at ? isoToEpochMs(frontmatter.deleted_at) : null,
|
|
264
|
+
};
|
|
265
|
+
return { observations: [observation], sha1 };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseSource(source, { projectRoot, userDir }) {
|
|
269
|
+
const content = readFileSync(source.path, 'utf8');
|
|
270
|
+
if (source.kind === 'scratchpad') {
|
|
271
|
+
return parseObservationsFromScratchpad({
|
|
272
|
+
path: source.path,
|
|
273
|
+
content,
|
|
274
|
+
tier: source.tier,
|
|
275
|
+
projectRoot,
|
|
276
|
+
userDir,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return parseObservationsFromFactFile({
|
|
280
|
+
path: source.path,
|
|
281
|
+
content,
|
|
282
|
+
tier: source.tier,
|
|
283
|
+
projectRoot,
|
|
284
|
+
userDir,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- DB write helpers -------------------------------------------------
|
|
289
|
+
|
|
290
|
+
const INSERT_OBSERVATION_SQL = `
|
|
291
|
+
INSERT INTO observations
|
|
292
|
+
(id, tier, source_file, source_line, source_sha1, heading_path, body,
|
|
293
|
+
write_source, trust, created_at, superseded_by, deleted_at)
|
|
294
|
+
VALUES
|
|
295
|
+
(@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
|
|
296
|
+
@write_source, @trust, @created_at, @superseded_by, @deleted_at)
|
|
297
|
+
`;
|
|
298
|
+
|
|
299
|
+
const UPSERT_FILE_SQL = `
|
|
300
|
+
INSERT INTO files (path, mtime, sha1, indexed_at)
|
|
301
|
+
VALUES (@path, @mtime, @sha1, @indexed_at)
|
|
302
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
303
|
+
mtime = excluded.mtime,
|
|
304
|
+
sha1 = excluded.sha1,
|
|
305
|
+
indexed_at = excluded.indexed_at
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
const DELETE_OBSERVATIONS_FOR_PATH_SQL = `DELETE FROM observations WHERE source_file = ?`;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Replace all observations for a single source file. Caller-wrapped
|
|
312
|
+
* in a transaction. The FTS5 delete-then-insert pattern fires the
|
|
313
|
+
* documented sync triggers (external-content sentinel + new insert).
|
|
314
|
+
*/
|
|
315
|
+
function replaceObservationsForFile(db, { source, observations, mtime, sha1, projectRoot, userDir, now }) {
|
|
316
|
+
const source_file = relativeSource(source.path, { projectRoot, userDir });
|
|
317
|
+
db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
|
|
318
|
+
const insert = db.prepare(INSERT_OBSERVATION_SQL);
|
|
319
|
+
for (const obs of observations) {
|
|
320
|
+
insert.run(obs);
|
|
321
|
+
}
|
|
322
|
+
db.prepare(UPSERT_FILE_SQL).run({
|
|
323
|
+
path: source_file,
|
|
324
|
+
mtime,
|
|
325
|
+
sha1,
|
|
326
|
+
indexed_at: now,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Public API: boot / full / watcher --------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Boot reindex: walk source files; reindex only those whose sha1
|
|
334
|
+
* differs from the `files` checkpoint.
|
|
335
|
+
*
|
|
336
|
+
* @returns {object} {filesScanned, filesReindexed, observationsAffected,
|
|
337
|
+
* durationMs, skipped: [{path, reason}]}
|
|
338
|
+
*/
|
|
339
|
+
export function reindexBoot({ projectRoot, userDir, db, now }) {
|
|
340
|
+
const t0 = Date.now();
|
|
341
|
+
const ts = now ?? t0;
|
|
342
|
+
const sources = listObservationSources({ projectRoot, userDir });
|
|
343
|
+
const skipped = [];
|
|
344
|
+
let filesScanned = 0;
|
|
345
|
+
let filesReindexed = 0;
|
|
346
|
+
let observationsAffected = 0;
|
|
347
|
+
|
|
348
|
+
const txn = db.transaction((source) => {
|
|
349
|
+
const stat = statSync(source.path);
|
|
350
|
+
const mtime = Math.floor(stat.mtimeMs);
|
|
351
|
+
const result = parseSource(source, { projectRoot, userDir });
|
|
352
|
+
if (result.skipped) {
|
|
353
|
+
skipped.push({ path: source.path, reason: result.skipped });
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
replaceObservationsForFile(db, {
|
|
357
|
+
source,
|
|
358
|
+
observations: result.observations,
|
|
359
|
+
mtime,
|
|
360
|
+
sha1: result.sha1,
|
|
361
|
+
projectRoot,
|
|
362
|
+
userDir,
|
|
363
|
+
now: ts,
|
|
364
|
+
});
|
|
365
|
+
return result.observations.length;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
for (const source of sources) {
|
|
369
|
+
filesScanned++;
|
|
370
|
+
const content = readFileSync(source.path, 'utf8');
|
|
371
|
+
const sha1 = sha1OfContent(content);
|
|
372
|
+
const relPath = relativeSource(source.path, { projectRoot, userDir });
|
|
373
|
+
const existing = db
|
|
374
|
+
.prepare('SELECT sha1 FROM files WHERE path = ?')
|
|
375
|
+
.get(relPath);
|
|
376
|
+
if (existing && existing.sha1 === sha1) {
|
|
377
|
+
continue; // unchanged
|
|
378
|
+
}
|
|
379
|
+
const n = txn(source);
|
|
380
|
+
filesReindexed++;
|
|
381
|
+
observationsAffected += n;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
filesScanned,
|
|
386
|
+
filesReindexed,
|
|
387
|
+
observationsAffected,
|
|
388
|
+
durationMs: Date.now() - t0,
|
|
389
|
+
skipped,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Full reindex: drop observations + observations_fts + files tables,
|
|
395
|
+
* re-apply the schema, then walk + reindex every source.
|
|
396
|
+
*
|
|
397
|
+
* Faster than DELETE FROM observations for large indexes because the
|
|
398
|
+
* FTS5 sentinel triggers don't fire per row.
|
|
399
|
+
*/
|
|
400
|
+
export function reindexFull({ projectRoot, userDir, db, now }) {
|
|
401
|
+
const t0 = Date.now();
|
|
402
|
+
const ts = now ?? t0;
|
|
403
|
+
// Drop + recreate (faster than per-row DELETE).
|
|
404
|
+
db.exec(`
|
|
405
|
+
DROP TABLE IF EXISTS observations_fts;
|
|
406
|
+
DROP TRIGGER IF EXISTS obs_after_insert;
|
|
407
|
+
DROP TRIGGER IF EXISTS obs_after_update;
|
|
408
|
+
DROP TRIGGER IF EXISTS obs_after_delete;
|
|
409
|
+
DROP TABLE IF EXISTS observations;
|
|
410
|
+
DROP TABLE IF EXISTS files;
|
|
411
|
+
`);
|
|
412
|
+
db.exec(INDEX_DB_SCHEMA);
|
|
413
|
+
|
|
414
|
+
const sources = listObservationSources({ projectRoot, userDir });
|
|
415
|
+
const skipped = [];
|
|
416
|
+
let filesScanned = 0;
|
|
417
|
+
let observationsAffected = 0;
|
|
418
|
+
|
|
419
|
+
const txn = db.transaction((source, sha1) => {
|
|
420
|
+
// sha1 is passed in (not recomputed) so the file-read for sha1
|
|
421
|
+
// matches the content parseSource will read again inside this txn.
|
|
422
|
+
// Tiny TOCTOU window: if the file changes between the outer read
|
|
423
|
+
// (sha1) and parseSource's read, the next reindex picks up the
|
|
424
|
+
// newest content — acceptable for a regenerable read-cache.
|
|
425
|
+
// Surfaced as Important finding I5 (dead `content` arg) in the
|
|
426
|
+
// Task 29 code-review; removed in this commit.
|
|
427
|
+
const stat = statSync(source.path);
|
|
428
|
+
const mtime = Math.floor(stat.mtimeMs);
|
|
429
|
+
const result = parseSource(source, { projectRoot, userDir });
|
|
430
|
+
if (result.skipped) {
|
|
431
|
+
skipped.push({ path: source.path, reason: result.skipped });
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
replaceObservationsForFile(db, {
|
|
435
|
+
source,
|
|
436
|
+
observations: result.observations,
|
|
437
|
+
mtime,
|
|
438
|
+
sha1,
|
|
439
|
+
projectRoot,
|
|
440
|
+
userDir,
|
|
441
|
+
now: ts,
|
|
442
|
+
});
|
|
443
|
+
return result.observations.length;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
for (const source of sources) {
|
|
447
|
+
filesScanned++;
|
|
448
|
+
const content = readFileSync(source.path, 'utf8');
|
|
449
|
+
const sha1 = sha1OfContent(content);
|
|
450
|
+
observationsAffected += txn(source, sha1);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
filesScanned,
|
|
455
|
+
observationsAffected,
|
|
456
|
+
durationMs: Date.now() - t0,
|
|
457
|
+
skipped,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Runtime watcher. Returns a handle with .close() so the caller (tests,
|
|
463
|
+
* future hook handler) can shut it down cleanly.
|
|
464
|
+
*
|
|
465
|
+
* Debounce via chokidar's awaitWriteFinish (stability threshold = the
|
|
466
|
+
* caller's debounceMs, default 500ms per design §9.2). On `add` /
|
|
467
|
+
* `change` events: re-parse the touched file + replace its observations.
|
|
468
|
+
* On `unlink`: delete the file's observations (FTS5 sentinel trigger
|
|
469
|
+
* fires for each).
|
|
470
|
+
*
|
|
471
|
+
* Tier inference: paths are matched against the resolved tier roots —
|
|
472
|
+
* a path starting with the projectRoot's context/ is P; context.local/
|
|
473
|
+
* is L; userDir is U.
|
|
474
|
+
*/
|
|
475
|
+
export function startRuntimeWatcher({
|
|
476
|
+
projectRoot,
|
|
477
|
+
userDir,
|
|
478
|
+
db,
|
|
479
|
+
debounceMs = 500,
|
|
480
|
+
}) {
|
|
481
|
+
// chokidar v5 dropped glob support (breaking change from v3). Watch
|
|
482
|
+
// the DIRECTORIES that contain observation-source files; filter events
|
|
483
|
+
// by extension + filename in the handlers. The MEMORY.md scratchpad
|
|
484
|
+
// is a single file so it can be watched directly; the memory/ fact
|
|
485
|
+
// directory is watched as a folder so chokidar receives 'add' events
|
|
486
|
+
// for newly-created per-fact files (e.g., from auto-extract's
|
|
487
|
+
// routeHigh writing a new fact).
|
|
488
|
+
const watchPaths = [];
|
|
489
|
+
const tierRoots = [];
|
|
490
|
+
for (const tier of ['P', 'L', 'U']) {
|
|
491
|
+
const root = resolveTierRoot({ tier, projectRoot, userDir });
|
|
492
|
+
if (!existsSync(root)) continue;
|
|
493
|
+
tierRoots.push({ tier, root });
|
|
494
|
+
const scratchpad = join(root, 'MEMORY.md');
|
|
495
|
+
if (existsSync(scratchpad)) watchPaths.push(scratchpad);
|
|
496
|
+
const factDir = resolveFactDir(tier, root);
|
|
497
|
+
if (existsSync(factDir)) watchPaths.push(factDir);
|
|
498
|
+
}
|
|
499
|
+
if (watchPaths.length === 0) {
|
|
500
|
+
return { close: async () => {}, watcher: null };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const watcher = chokidar.watch(watchPaths, {
|
|
504
|
+
ignoreInitial: true,
|
|
505
|
+
persistent: true,
|
|
506
|
+
awaitWriteFinish: {
|
|
507
|
+
stabilityThreshold: debounceMs,
|
|
508
|
+
pollInterval: 100,
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
function tierForPath(p) {
|
|
513
|
+
const np = p.replaceAll('\\', '/');
|
|
514
|
+
for (const { tier, root } of tierRoots) {
|
|
515
|
+
const nr = root.replaceAll('\\', '/');
|
|
516
|
+
// Sibling-prefix guard (I2): require a `/` after the root prefix
|
|
517
|
+
// so "/foo-other/..." doesn't match "/foo". Same logic as
|
|
518
|
+
// relativeSource's isInside helper.
|
|
519
|
+
if (np === nr || np.startsWith(nr + '/')) return tier;
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function kindForPath(p) {
|
|
525
|
+
const np = p.replaceAll('\\', '/');
|
|
526
|
+
return /\/memory\/[^/]+\.md$/.test(np) ? 'fact' : 'scratchpad';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function isObservationSource(absPath) {
|
|
530
|
+
// Filter chokidar events. Watch is over directories; this filter
|
|
531
|
+
// drops non-.md files, INDEX.md (Task 8's pointer index — not an
|
|
532
|
+
// observation source), and anything outside the kit's tier roots.
|
|
533
|
+
if (!absPath.endsWith('.md')) return false;
|
|
534
|
+
if (basename(absPath) === 'INDEX.md') return false;
|
|
535
|
+
return VALID_TIERS.has(tierForPath(absPath));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function handleChange(absPath) {
|
|
539
|
+
if (!isObservationSource(absPath)) return;
|
|
540
|
+
const tier = tierForPath(absPath);
|
|
541
|
+
const kind = kindForPath(absPath);
|
|
542
|
+
const source = { path: absPath, tier, kind };
|
|
543
|
+
try {
|
|
544
|
+
const content = readFileSync(absPath, 'utf8');
|
|
545
|
+
const sha1 = sha1OfContent(content);
|
|
546
|
+
const result = parseSource(source, { projectRoot, userDir });
|
|
547
|
+
if (result.skipped) return;
|
|
548
|
+
const stat = statSync(absPath);
|
|
549
|
+
const mtime = Math.floor(stat.mtimeMs);
|
|
550
|
+
const txn = db.transaction(() => {
|
|
551
|
+
replaceObservationsForFile(db, {
|
|
552
|
+
source,
|
|
553
|
+
observations: result.observations,
|
|
554
|
+
mtime,
|
|
555
|
+
sha1,
|
|
556
|
+
projectRoot,
|
|
557
|
+
userDir,
|
|
558
|
+
now: Date.now(),
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
txn();
|
|
562
|
+
} catch (err) {
|
|
563
|
+
// Best-effort: a partial write or temp-file might trigger an event
|
|
564
|
+
// for a file that's already been replaced. Re-fire on the next event.
|
|
565
|
+
// Log to stderr with the file path so a poison-pill file doesn't
|
|
566
|
+
// fail silently — surfaced as Minor finding M4 by the Task 29
|
|
567
|
+
// code-review.
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
`cmk runtime-watcher: skipped ${absPath}: ${err?.message ?? err}\n`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function handleUnlink(absPath) {
|
|
575
|
+
if (!isObservationSource(absPath)) return;
|
|
576
|
+
const source_file = relativeSource(absPath, { projectRoot, userDir });
|
|
577
|
+
const txn = db.transaction(() => {
|
|
578
|
+
db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
|
|
579
|
+
db.prepare('DELETE FROM files WHERE path = ?').run(source_file);
|
|
580
|
+
});
|
|
581
|
+
txn();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
watcher.on('add', handleChange);
|
|
585
|
+
watcher.on('change', handleChange);
|
|
586
|
+
watcher.on('unlink', handleUnlink);
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
watcher,
|
|
590
|
+
close: () => watcher.close(),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// `parseBulletProvenance` re-export so a future test can probe a comment
|
|
595
|
+
// in isolation without re-importing from provenance.mjs. Kept narrow to
|
|
596
|
+
// avoid widening the module's API beyond what callers need.
|
|
597
|
+
export { parseBulletProvenance };
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// cmk CLI — top-level commander wiring.
|
|
2
|
+
// Task 2 (T-002) ships ONLY stubs. Each subcommand prints a
|
|
3
|
+
// "not yet implemented in v0.1.0 milestone N" message identifying
|
|
4
|
+
// the tasks.md task that lights it up. Every stub exits 0 — they are
|
|
5
|
+
// valid invocations of a CLI that doesn't do anything yet.
|
|
6
|
+
//
|
|
7
|
+
// Per tasks.md "Engineering discipline":
|
|
8
|
+
// - Deep modules: the subcommand registry is one module here.
|
|
9
|
+
// - Boundary testing: tests assert what `cmk --help` lists, what
|
|
10
|
+
// stubs output, and that exit codes are 0 — NOT how commander
|
|
11
|
+
// happens to format help text internally.
|
|
12
|
+
|
|
13
|
+
import { Command, Option } from 'commander';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { subcommands } from './subcommands.mjs';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const PKG_ROOT = join(dirname(__filename), '..');
|
|
21
|
+
|
|
22
|
+
function readPackageVersion() {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
24
|
+
return pkg.version;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the Commander program with every documented subcommand wired in
|
|
29
|
+
* as a stub. Exported separately so tests can introspect the program
|
|
30
|
+
* without invoking it.
|
|
31
|
+
*/
|
|
32
|
+
export function buildProgram() {
|
|
33
|
+
const program = new Command();
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.name('cmk')
|
|
37
|
+
.description(
|
|
38
|
+
'claude-memory-kit — per-project, in-repo memory system for Claude Code. ' +
|
|
39
|
+
'Run `cmk install` to scaffold a project, `cmk doctor` to verify health.'
|
|
40
|
+
)
|
|
41
|
+
.version(readPackageVersion(), '-V, --version', 'print the cmk version + exit');
|
|
42
|
+
|
|
43
|
+
for (const sub of subcommands) {
|
|
44
|
+
const cmd = program.command(sub.name).description(sub.description);
|
|
45
|
+
|
|
46
|
+
// Attach positional + flag declarations if the stub declares them.
|
|
47
|
+
if (sub.argSpec) {
|
|
48
|
+
for (const a of sub.argSpec) cmd.argument(a.flags, a.description);
|
|
49
|
+
}
|
|
50
|
+
if (sub.optionSpec) {
|
|
51
|
+
for (const o of sub.optionSpec) cmd.addOption(new Option(o.flags, o.description));
|
|
52
|
+
}
|
|
53
|
+
if (sub.children) {
|
|
54
|
+
for (const child of sub.children) {
|
|
55
|
+
const childCmd = cmd
|
|
56
|
+
.command(child.name)
|
|
57
|
+
.description(child.description);
|
|
58
|
+
if (child.argSpec) for (const a of child.argSpec) childCmd.argument(a.flags, a.description);
|
|
59
|
+
if (child.optionSpec) for (const o of child.optionSpec) childCmd.addOption(new Option(o.flags, o.description));
|
|
60
|
+
// Task 42 B3 fix (skill-review 2026-05-28): when a child has
|
|
61
|
+
// its OWN action (Task 38 transcripts/extract is the precedent),
|
|
62
|
+
// wire that directly so its options propagate to its handler.
|
|
63
|
+
// Falling back to `sub.action(child.name)` was a stub-era
|
|
64
|
+
// pattern from Task 2 that broke once children grew real
|
|
65
|
+
// logic — `transcripts` has no parent action and `cmk
|
|
66
|
+
// transcripts extract` crashed with TypeError.
|
|
67
|
+
if (typeof child.action === 'function') {
|
|
68
|
+
childCmd.action((...cmdArgs) => child.action(...cmdArgs));
|
|
69
|
+
} else {
|
|
70
|
+
childCmd.action(() => sub.action(child.name));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
cmd.action((...cmdArgs) => sub.action(...cmdArgs));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return program;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse argv and dispatch. Returns a Promise that resolves after the
|
|
83
|
+
* matched subcommand action returns. Exits 0 on stub success.
|
|
84
|
+
*
|
|
85
|
+
* @param {string[]} argv - typically process.argv
|
|
86
|
+
*/
|
|
87
|
+
export async function run(argv) {
|
|
88
|
+
const program = buildProgram();
|
|
89
|
+
await program.parseAsync(argv);
|
|
90
|
+
}
|