@lh8ppl/claude-memory-kit 0.2.4 → 0.3.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/README.md +13 -10
- package/bin/cmk-capture-prompt.mjs +21 -1
- package/package.json +2 -1
- package/src/auto-extract.mjs +68 -11
- package/src/capture-prompt.mjs +33 -1
- package/src/capture-turn.mjs +64 -6
- package/src/conflict-queue.mjs +20 -3
- package/src/forget.mjs +13 -0
- package/src/frontmatter.mjs +4 -1
- package/src/import-anthropic-memory.mjs +25 -1
- package/src/index-db.mjs +39 -0
- package/src/index-rebuild.mjs +42 -2
- package/src/inject-context.mjs +49 -6
- package/src/install.mjs +107 -1
- package/src/mcp-server.mjs +57 -7
- package/src/merge-facts.mjs +12 -0
- package/src/provenance.mjs +4 -0
- package/src/result-shapes.mjs +1 -1
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +96 -9
- package/src/semantic-backend.mjs +485 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/subcommands.mjs +92 -16
- package/src/transcript-index.mjs +162 -0
- package/src/turn-tools.mjs +179 -0
- package/template/.claude/skills/memory-search/SKILL.md +86 -0
- package/template/CLAUDE.md.template +2 -0
package/src/subcommands.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { install as installAction, initUserTier as initUserTierAction } from './
|
|
|
17
17
|
import { removeClaudeMdBlock } from './claude-md.mjs';
|
|
18
18
|
import { reindex as reindexAction } from './reindex.mjs';
|
|
19
19
|
import { openIndexDb } from './index-db.mjs';
|
|
20
|
+
import { resolveDefaultSearchMode } from './semantic-backend.mjs';
|
|
20
21
|
import { reindexBoot, reindexFull } from './index-rebuild.mjs';
|
|
21
22
|
import { search as searchAction, SEARCH_MODES } from './search.mjs';
|
|
22
23
|
import { memoryWrite } from './memory-write.mjs';
|
|
@@ -63,6 +64,32 @@ import { resolve as resolvePath, join, basename } from 'node:path';
|
|
|
63
64
|
|
|
64
65
|
const NOTICE_PREFIX = 'not yet implemented';
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The install summary line for the Task-46 semantic outcome (Task 125.4:
|
|
69
|
+
* pure + exported so the branches are testable without running install).
|
|
70
|
+
* Returns null when there is nothing to print: an `error` action already
|
|
71
|
+
* surfaces through result.errors, and the opt-in tip is suppressed under
|
|
72
|
+
* --no-hooks (scaffold-only installs).
|
|
73
|
+
*/
|
|
74
|
+
export function formatSemanticSummary(semantic, { noHooks = false } = {}) {
|
|
75
|
+
if (semantic?.action === 'enabled') {
|
|
76
|
+
const w = semantic.warmed;
|
|
77
|
+
return (
|
|
78
|
+
' Semantic recall ENABLED — `cmk search` now defaults to hybrid here.' +
|
|
79
|
+
(w?.ok
|
|
80
|
+
? ` Model cached (${Math.round(w.ms / 1000)}s).`
|
|
81
|
+
: ' Model downloads on first search.')
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (semantic?.action === 'disabled') {
|
|
85
|
+
return ' Semantic recall pinned OFF for this project (search.default_mode=keyword).';
|
|
86
|
+
}
|
|
87
|
+
if (semantic?.action === 'skipped' && !noHooks) {
|
|
88
|
+
return ' Tip: `cmk install --with-semantic` adds local semantic recall (ask in your own words; one-time ~260 MB, no API calls).';
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
/**
|
|
67
94
|
* Real `cmk install` action — wired in Task 3, extended in Task 4 with
|
|
68
95
|
* --force passed through to the CLAUDE.md downgrade guard. Reads CLI
|
|
@@ -74,7 +101,16 @@ async function runInstall(options /* , command */) {
|
|
|
74
101
|
// commander maps `--no-hooks` to options.hooks === false.
|
|
75
102
|
const noHooks = !!(options && options.hooks === false);
|
|
76
103
|
const verbose = !!(options && options.verbose);
|
|
77
|
-
const result = await installAction({
|
|
104
|
+
const result = await installAction({
|
|
105
|
+
force: !!(options && options.force),
|
|
106
|
+
noHooks,
|
|
107
|
+
// Task 46: two flags, 3-state semantics (enable / pin-off / untouched).
|
|
108
|
+
// commander maps `--no-semantic` to options.semantic === false (the
|
|
109
|
+
// same negation pattern as --no-hooks above); `--with-semantic` maps
|
|
110
|
+
// to options.withSemantic.
|
|
111
|
+
withSemantic: !!(options && options.withSemantic),
|
|
112
|
+
noSemantic: !!(options && options.semantic === false),
|
|
113
|
+
});
|
|
78
114
|
|
|
79
115
|
// Outcome over inventory (self-test UX finding): state the resulting state +
|
|
80
116
|
// next action, not a file tally. The old "scaffolded 5, skipped 4 existing"
|
|
@@ -112,6 +148,11 @@ async function runInstall(options /* , command */) {
|
|
|
112
148
|
// opted out, so we don't nag).
|
|
113
149
|
const nativeNote = nativeMemoryInstallNote(result.projectRoot);
|
|
114
150
|
if (nativeNote) console.log(nativeNote);
|
|
151
|
+
// Task 46: semantic-recall outcome (pure formatter, Task 125.4 — testable
|
|
152
|
+
// without spawning install; the error case returns null because enableSemantic
|
|
153
|
+
// errors already land in result.errors and print through the error path).
|
|
154
|
+
const semanticLine = formatSemanticSummary(result.semantic, { noHooks });
|
|
155
|
+
if (semanticLine) console.log(semanticLine);
|
|
115
156
|
if (verbose) {
|
|
116
157
|
console.log(
|
|
117
158
|
` files: ${result.created.length} created, ${result.skipped.length} already present` +
|
|
@@ -251,8 +292,9 @@ function runLessonsPromote(id, options = {}) {
|
|
|
251
292
|
*
|
|
252
293
|
* The keyword backend (FTS5 BM25 over the observations index) always
|
|
253
294
|
* ships. Semantic + hybrid modes require the Layer-5b semantic backend,
|
|
254
|
-
*
|
|
255
|
-
*
|
|
295
|
+
* (Task 65: prepared automatically when the optional embedder is installed;
|
|
296
|
+
* absent embedder errors with exit code 2 + an install hint, per the 30.2
|
|
297
|
+
* contract). The `semanticBackend` DI seam
|
|
256
298
|
* is the drop-in point for the future backend.
|
|
257
299
|
*
|
|
258
300
|
* Filter flags (per tasks.md 30.4):
|
|
@@ -263,7 +305,7 @@ function runLessonsPromote(id, options = {}) {
|
|
|
263
305
|
* --limit <N> (default 20)
|
|
264
306
|
* --include-tombstoned (default false)
|
|
265
307
|
*/
|
|
266
|
-
function runSearch(queryParts, options) {
|
|
308
|
+
async function runSearch(queryParts, options) {
|
|
267
309
|
const projectRoot = resolvePath(process.cwd());
|
|
268
310
|
const userDir =
|
|
269
311
|
process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
@@ -287,15 +329,51 @@ function runSearch(queryParts, options) {
|
|
|
287
329
|
'searching the existing index. Run `cmk reindex --full` if results look stale.',
|
|
288
330
|
);
|
|
289
331
|
}
|
|
332
|
+
// Task 65: semantic/hybrid prepare the REAL embedded backend (async —
|
|
333
|
+
// search() itself stays sync; the seam gets a sync closure over the
|
|
334
|
+
// pre-embedded query vector). Task 46: an explicit --mode wins;
|
|
335
|
+
// otherwise the project's configured default (context/settings.json
|
|
336
|
+
// search.default_mode, set by `cmk install --with-semantic`), falling
|
|
337
|
+
// back to keyword. Explicit-but-unavailable → exit 2 + hint (the 30.2
|
|
338
|
+
// contract); configured-but-unavailable → graceful keyword fallback
|
|
339
|
+
// (the default must never break every search).
|
|
340
|
+
const explicitMode = options?.mode;
|
|
341
|
+
let mode = explicitMode ?? resolveDefaultSearchMode({ projectRoot });
|
|
342
|
+
// Task 104.2 — the L3 raw tier: `--scope transcripts` searches the
|
|
343
|
+
// separate transcript-chunk index (synthetic T: ids; no tier/trust).
|
|
344
|
+
const scope = options?.scope ?? 'facts';
|
|
345
|
+
let semanticBackend;
|
|
346
|
+
if (mode === SEARCH_MODES.SEMANTIC || mode === SEARCH_MODES.HYBRID) {
|
|
347
|
+
const { prepareSemanticBackend } = await import('./semantic-backend.mjs');
|
|
348
|
+
const prep = await prepareSemanticBackend({ db, query, scope });
|
|
349
|
+
if (!prep.ok && explicitMode) {
|
|
350
|
+
console.error(
|
|
351
|
+
`cmk search: semantic backend unavailable (${prep.reason}).` +
|
|
352
|
+
(prep.hint ? `\n ${prep.hint}` : ' Use --mode=keyword.'),
|
|
353
|
+
);
|
|
354
|
+
process.exitCode = 2;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!prep.ok) {
|
|
358
|
+
console.error(
|
|
359
|
+
`cmk search: semantic default unavailable (${prep.reason}) — falling back to keyword.`,
|
|
360
|
+
);
|
|
361
|
+
mode = SEARCH_MODES.KEYWORD;
|
|
362
|
+
} else {
|
|
363
|
+
semanticBackend = prep.backend;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
290
366
|
const r = searchAction({
|
|
291
367
|
db,
|
|
292
368
|
query,
|
|
293
|
-
mode
|
|
369
|
+
mode,
|
|
370
|
+
scope,
|
|
294
371
|
minTrust: options?.minTrust,
|
|
295
372
|
tier: options?.tier,
|
|
296
373
|
since: options?.since,
|
|
297
374
|
limit: options?.limit !== undefined ? Number(options.limit) : undefined,
|
|
298
375
|
includeTombstoned: options?.includeTombstoned === true,
|
|
376
|
+
semanticBackend,
|
|
299
377
|
});
|
|
300
378
|
if (r.action === 'error') {
|
|
301
379
|
for (const e of r.errors) console.error(`cmk search: ${e}`);
|
|
@@ -311,13 +389,15 @@ function runSearch(queryParts, options) {
|
|
|
311
389
|
for (const hit of r.results) {
|
|
312
390
|
// Plain-text output suitable for terminal piping. Snippet uses
|
|
313
391
|
// FTS5's <b>...</b> markers; preserved as-is so callers can pipe
|
|
314
|
-
// to a TUI that renders them OR strip via sed.
|
|
392
|
+
// to a TUI that renders them OR strip via sed. Transcript hits carry
|
|
393
|
+
// no tier/trust (raw chunks) — the column shows the scope instead.
|
|
394
|
+
const provenance = hit.tier ? `${hit.tier}/${hit.trust}` : 'transcript';
|
|
315
395
|
console.log(
|
|
316
|
-
`${hit.id}\t${
|
|
396
|
+
`${hit.id}\t${provenance}\t${hit.source_file}:${hit.source_line}\t${hit.snippet}`,
|
|
317
397
|
);
|
|
318
398
|
}
|
|
319
399
|
console.log(
|
|
320
|
-
`\ncmk search: ${r.results.length} result(s) (mode=${r.mode})`,
|
|
400
|
+
`\ncmk search: ${r.results.length} result(s) (mode=${r.mode}${r.scope && r.scope !== 'facts' ? `, scope=${r.scope}` : ''})`,
|
|
321
401
|
);
|
|
322
402
|
} finally {
|
|
323
403
|
db.close();
|
|
@@ -1611,6 +1691,8 @@ export const subcommands = [
|
|
|
1611
1691
|
optionSpec: [
|
|
1612
1692
|
{ flags: '--force', description: 'allow downgrade of an existing newer-version CLAUDE.md block' },
|
|
1613
1693
|
{ flags: '--no-hooks', description: 'scaffold only; do NOT wire hooks into .claude/settings.json' },
|
|
1694
|
+
{ flags: '--with-semantic', description: 'enable semantic recall: install the local embedder (~260 MB once), default search to hybrid, pre-warm the model' },
|
|
1695
|
+
{ flags: '--no-semantic', description: 'pin keyword-only search for this project (writes search.default_mode=keyword)' },
|
|
1614
1696
|
{ flags: '--verbose', description: 'show the per-tier created/skipped file breakdown' },
|
|
1615
1697
|
],
|
|
1616
1698
|
action: runInstall,
|
|
@@ -1653,7 +1735,8 @@ export const subcommands = [
|
|
|
1653
1735
|
milestone: 30,
|
|
1654
1736
|
argSpec: [{ flags: '<query...>', description: 'query terms' }],
|
|
1655
1737
|
optionSpec: [
|
|
1656
|
-
{ flags: '--mode <mode>', description: 'keyword | semantic | hybrid (default: keyword; semantic + hybrid
|
|
1738
|
+
{ flags: '--mode <mode>', description: 'keyword | semantic | hybrid (default: keyword; semantic + hybrid use the embedded Layer-5b backend — needs the optional @huggingface/transformers embedder)' },
|
|
1739
|
+
{ flags: '--scope <scope>', description: 'facts | transcripts (default: facts — curated memory; transcripts = the raw session record, the last-resort recall tier)' },
|
|
1657
1740
|
{ flags: '--min-trust <level>', description: 'low | medium | high' },
|
|
1658
1741
|
{ flags: '--tier <tier>', description: 'U | P | L (filter to a single tier)' },
|
|
1659
1742
|
{ flags: '--since <date>', description: 'ISO date — exclude observations older than this' },
|
|
@@ -1737,13 +1820,6 @@ export const subcommands = [
|
|
|
1737
1820
|
],
|
|
1738
1821
|
action: stub('config', 'v0.1.x'),
|
|
1739
1822
|
},
|
|
1740
|
-
{
|
|
1741
|
-
name: 'view',
|
|
1742
|
-
description: 'open a local markdown viewer at 127.0.0.1:37778',
|
|
1743
|
-
milestone: 'v0.1.x',
|
|
1744
|
-
optionSpec: [{ flags: '--port <n>', description: 'override default port 37778' }],
|
|
1745
|
-
action: stub('view', 'v0.1.x'),
|
|
1746
|
-
},
|
|
1747
1823
|
{
|
|
1748
1824
|
name: 'import-anthropic-memory',
|
|
1749
1825
|
description: "merge useful bullets from Anthropic's auto-memory into this project's MEMORY.md",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Task 104.2 (D-117) — transcript chunking + index sync: the SEARCH half of
|
|
2
|
+
// the L3 raw tier (the capture half shipped in 104.1). Transcript files
|
|
3
|
+
// (context/transcripts/{date}.md — dialogue + per-turn Tools blocks) are
|
|
4
|
+
// chunked by `## ` turn headings and windowed to ≤1500 chars (the memsearch
|
|
5
|
+
// chunking rule Task 65 adopted), then synced into the SEPARATE
|
|
6
|
+
// transcript_chunks table (index-db.mjs) so `cmk search --scope transcripts`
|
|
7
|
+
// reaches them WITHOUT polluting L1 fact results (the MemPalace last-resort
|
|
8
|
+
// contract, D-70/D-72).
|
|
9
|
+
//
|
|
10
|
+
// Sync strategy mirrors the observation indexer: per-file mtime/sha1 rows in
|
|
11
|
+
// the shared `files` table (keyed with a 'transcript:' prefix so they never
|
|
12
|
+
// collide with observation sources) → unchanged files cost one stat.
|
|
13
|
+
//
|
|
14
|
+
// Public boundary:
|
|
15
|
+
// chunkTranscript(text) → [{heading, body, sourceLine, chunkIdx}] (pure)
|
|
16
|
+
// syncTranscriptChunks({db, projectRoot, now?}) → {files, chunks}
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const CHUNK_MAX_CHARS = 1500; // the Task-65 / memsearch chunking rule
|
|
23
|
+
const FILES_KEY_PREFIX = 'transcript:';
|
|
24
|
+
|
|
25
|
+
export function chunkTranscript(text) {
|
|
26
|
+
if (typeof text !== 'string' || text.trim() === '') return [];
|
|
27
|
+
const lines = text.split(/\r?\n/);
|
|
28
|
+
// Locate turn headings (`## <ts> — speaker`, the capture-prompt/-turn shape).
|
|
29
|
+
const headings = [];
|
|
30
|
+
for (let i = 0; i < lines.length; i++) {
|
|
31
|
+
if (/^##\s/.test(lines[i])) headings.push(i);
|
|
32
|
+
}
|
|
33
|
+
if (headings.length === 0) return [];
|
|
34
|
+
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let chunkIdx = 0;
|
|
37
|
+
for (let h = 0; h < headings.length; h++) {
|
|
38
|
+
const start = headings[h];
|
|
39
|
+
const end = h + 1 < headings.length ? headings[h + 1] : lines.length;
|
|
40
|
+
const heading = lines[start].trim();
|
|
41
|
+
const body = lines
|
|
42
|
+
.slice(start + 1, end)
|
|
43
|
+
.join('\n')
|
|
44
|
+
.trim();
|
|
45
|
+
if (body === '') continue;
|
|
46
|
+
// Window oversized turns; every window keeps its turn heading so a hit
|
|
47
|
+
// is always attributable to a specific turn.
|
|
48
|
+
for (let off = 0; off < body.length; off += CHUNK_MAX_CHARS) {
|
|
49
|
+
chunks.push({
|
|
50
|
+
heading,
|
|
51
|
+
body: body.slice(off, off + CHUNK_MAX_CHARS),
|
|
52
|
+
sourceLine: start + 1, // 1-based heading line — the drill-back anchor
|
|
53
|
+
chunkIdx: chunkIdx++,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return chunks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sha1(text) {
|
|
61
|
+
return createHash('sha1').update(text, 'utf8').digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Task 126 (D-119) — the raw-tier scope covers BOTH halves of the session
|
|
65
|
+
// record: verbatim transcripts AND the Haiku-compressed sessions summaries
|
|
66
|
+
// (today-*.md / recent.md / archive.md — the middle tier that was otherwise
|
|
67
|
+
// a recall blind spot: discussed-but-never-graduated content). Exclusions:
|
|
68
|
+
// now.md (the volatile live buffer — already in context, and its constant
|
|
69
|
+
// truncation would churn the index) and non-.md observability files.
|
|
70
|
+
const RAW_TIER_DIRS = ['transcripts', 'sessions'];
|
|
71
|
+
const SESSIONS_EXCLUDE = new Set(['now.md']);
|
|
72
|
+
|
|
73
|
+
export function syncTranscriptChunks({ db, projectRoot, now = Date.now() } = {}) {
|
|
74
|
+
let files = 0;
|
|
75
|
+
let chunks = 0;
|
|
76
|
+
|
|
77
|
+
const entries = []; // {abs, sourceFile}
|
|
78
|
+
for (const sub of RAW_TIER_DIRS) {
|
|
79
|
+
const dir = join(projectRoot, 'context', sub);
|
|
80
|
+
if (!existsSync(dir)) continue;
|
|
81
|
+
let names;
|
|
82
|
+
try {
|
|
83
|
+
names = readdirSync(dir).filter(
|
|
84
|
+
(n) => n.endsWith('.md') && !(sub === 'sessions' && SESSIONS_EXCLUDE.has(n)),
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
entries.push({ abs: join(dir, name), sourceFile: `context/${sub}/${name}` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const getFileRow = db.prepare('SELECT mtime, sha1 FROM files WHERE path = ?');
|
|
95
|
+
const upsertFileRow = db.prepare(
|
|
96
|
+
'INSERT INTO files (path, mtime, sha1, indexed_at) VALUES (?, ?, ?, ?) ' +
|
|
97
|
+
'ON CONFLICT(path) DO UPDATE SET mtime = excluded.mtime, sha1 = excluded.sha1, indexed_at = excluded.indexed_at',
|
|
98
|
+
);
|
|
99
|
+
const deleteChunks = db.prepare('DELETE FROM transcript_chunks WHERE source_file = ?');
|
|
100
|
+
const insertChunk = db.prepare(
|
|
101
|
+
'INSERT INTO transcript_chunks (source_file, chunk_idx, source_line, heading, body) VALUES (?, ?, ?, ?, ?)',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
for (const { abs, sourceFile } of entries) {
|
|
105
|
+
const filesKey = FILES_KEY_PREFIX + sourceFile;
|
|
106
|
+
let st;
|
|
107
|
+
try {
|
|
108
|
+
st = statSync(abs);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const prev = getFileRow.get(filesKey);
|
|
113
|
+
// NO mtime fast-path: two appends inside the filesystem's mtime
|
|
114
|
+
// resolution would make the second invisible (caught as a flaky test —
|
|
115
|
+
// rapid Stop hooks are the same shape in production). sha1 is the
|
|
116
|
+
// authority; day-files are small and reindex reads its other sources
|
|
117
|
+
// anyway, so the read cost is negligible.
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
text = readFileSync(abs, 'utf8');
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const digest = sha1(text);
|
|
125
|
+
if (prev && prev.sha1 === digest) {
|
|
126
|
+
continue; // content unchanged
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parsed = chunkTranscript(text);
|
|
130
|
+
const replaceFile = db.transaction(() => {
|
|
131
|
+
deleteChunks.run(sourceFile);
|
|
132
|
+
for (const c of parsed) {
|
|
133
|
+
insertChunk.run(sourceFile, c.chunkIdx, c.sourceLine, c.heading, c.body);
|
|
134
|
+
}
|
|
135
|
+
upsertFileRow.run(filesKey, Math.trunc(st.mtimeMs), digest, now);
|
|
136
|
+
});
|
|
137
|
+
replaceFile();
|
|
138
|
+
files += 1;
|
|
139
|
+
chunks += parsed.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Orphan-prune for THIS scope: a deleted/rotated file (transcripts OR
|
|
143
|
+
// sessions — weekly-curate rotates today-*.md into archive.md) leaves its
|
|
144
|
+
// chunks + checkpoint behind otherwise. The observation indexer's prune
|
|
145
|
+
// deliberately skips 'transcript:' rows (they are not observation sources)
|
|
146
|
+
// — pruning them is this function's job, scoped by the key prefix.
|
|
147
|
+
const live = new Set(entries.map((e) => FILES_KEY_PREFIX + e.sourceFile));
|
|
148
|
+
const known = db
|
|
149
|
+
.prepare("SELECT path FROM files WHERE path LIKE ?")
|
|
150
|
+
.all(FILES_KEY_PREFIX + '%');
|
|
151
|
+
const pruneTxn = db.transaction((filesKey) => {
|
|
152
|
+
db.prepare('DELETE FROM transcript_chunks WHERE source_file = ?').run(
|
|
153
|
+
filesKey.slice(FILES_KEY_PREFIX.length),
|
|
154
|
+
);
|
|
155
|
+
db.prepare('DELETE FROM files WHERE path = ?').run(filesKey);
|
|
156
|
+
});
|
|
157
|
+
for (const { path } of known) {
|
|
158
|
+
if (!live.has(path)) pruneTxn(path);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { files, chunks };
|
|
162
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Task 104.1 — extract the CURRENT turn's tool activity from Anthropic's
|
|
2
|
+
// session JSONL (the Stop payload's `transcript_path`) so capture-turn can
|
|
3
|
+
// enrich the kit's own committed transcript (the L3 raw tier of the recall
|
|
4
|
+
// waterfall, design §19 / D-117).
|
|
5
|
+
//
|
|
6
|
+
// Why read the live JSONL: it is the only record of tool calls + results
|
|
7
|
+
// (the Stop payload itself carries only the assistant TEXT), and it expires
|
|
8
|
+
// (~30 days, machine-local) — we extract the current turn into OUR format at
|
|
9
|
+
// capture time; we never copy/snapshot the file (the user's 2026-06-06
|
|
10
|
+
// directive: enriching our own transcript, not a JSONL crutch).
|
|
11
|
+
//
|
|
12
|
+
// The JSONL internal format is NOT a documented Anthropic contract (only
|
|
13
|
+
// `transcript_path` is). Shapes below were verified EMPIRICALLY across 6
|
|
14
|
+
// sessions / 4 projects (2026-06-10):
|
|
15
|
+
// - entries: {type: 'user'|'assistant'|<harness types to skip>, message?}
|
|
16
|
+
// - message.content: a block LIST or a plain STRING (both real)
|
|
17
|
+
// - blocks: text / thinking / tool_use {id,name,input} / tool_result
|
|
18
|
+
// {tool_use_id, content: STRING or LIST of {type:'text',text}}
|
|
19
|
+
// - tool_result blocks ride USER-role entries (API convention) — a user
|
|
20
|
+
// entry is a real prompt boundary ONLY if it has text and no tool_result.
|
|
21
|
+
// Everything here is defensive: unrecognized shapes are skipped; any failure
|
|
22
|
+
// returns null. A format shift degrades the enrichment, never the capture.
|
|
23
|
+
//
|
|
24
|
+
// Public boundary:
|
|
25
|
+
// extractTurnToolActivity(jsonlText) → string|null (pure)
|
|
26
|
+
// readTranscriptTail(path, maxBytes?) → string (bounded file read)
|
|
27
|
+
|
|
28
|
+
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
29
|
+
|
|
30
|
+
// Caps (git-bloat control, the D-117 sub-decision (a)): one turn's Tools
|
|
31
|
+
// block stays a small fraction of a transcript day.
|
|
32
|
+
const RESULT_SNIPPET_CHARS = 300;
|
|
33
|
+
const INPUT_SUMMARY_CHARS = 160;
|
|
34
|
+
const BLOCK_CAP_CHARS = 4000;
|
|
35
|
+
// Tail bound: one turn comfortably fits; a mega-session file is never read whole.
|
|
36
|
+
const DEFAULT_TAIL_BYTES = 768 * 1024;
|
|
37
|
+
|
|
38
|
+
// The most informative input field per common tool; unknown tools fall back
|
|
39
|
+
// to a compact JSON summary. Order matters — first present key wins.
|
|
40
|
+
const REPRESENTATIVE_INPUT_KEYS = [
|
|
41
|
+
'command',
|
|
42
|
+
'file_path',
|
|
43
|
+
'pattern',
|
|
44
|
+
'query',
|
|
45
|
+
'url',
|
|
46
|
+
'path',
|
|
47
|
+
'prompt',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function oneLine(s, max) {
|
|
51
|
+
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
52
|
+
return flat.length > max ? flat.slice(0, max) + '…' : flat;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarizeInput(input) {
|
|
56
|
+
if (!input || typeof input !== 'object') return '';
|
|
57
|
+
for (const key of REPRESENTATIVE_INPUT_KEYS) {
|
|
58
|
+
if (typeof input[key] === 'string' && input[key].trim() !== '') {
|
|
59
|
+
return oneLine(input[key], INPUT_SUMMARY_CHARS);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return oneLine(JSON.stringify(input), INPUT_SUMMARY_CHARS);
|
|
64
|
+
} catch {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function flattenResultContent(content) {
|
|
70
|
+
if (typeof content === 'string') return content;
|
|
71
|
+
if (Array.isArray(content)) {
|
|
72
|
+
return content
|
|
73
|
+
.map((b) => (b && typeof b === 'object' && typeof b.text === 'string' ? b.text : ''))
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join(' ');
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function contentBlocks(message) {
|
|
81
|
+
const c = message?.content;
|
|
82
|
+
return Array.isArray(c) ? c.filter((b) => b && typeof b === 'object') : [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A user entry is a REAL prompt boundary when it carries prompt text (string
|
|
86
|
+
// content or a text block) and no tool_result blocks (results ride user role).
|
|
87
|
+
function isRealUserPrompt(entry) {
|
|
88
|
+
if (entry?.type !== 'user') return false;
|
|
89
|
+
const c = entry.message?.content;
|
|
90
|
+
if (typeof c === 'string') return c.trim() !== '';
|
|
91
|
+
const blocks = contentBlocks(entry.message);
|
|
92
|
+
if (blocks.some((b) => b.type === 'tool_result')) return false;
|
|
93
|
+
return blocks.some((b) => b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function extractTurnToolActivity(jsonlText) {
|
|
97
|
+
if (typeof jsonlText !== 'string' || jsonlText.trim() === '') return null;
|
|
98
|
+
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const raw of jsonlText.split('\n')) {
|
|
101
|
+
if (raw.trim() === '') continue;
|
|
102
|
+
try {
|
|
103
|
+
const e = JSON.parse(raw);
|
|
104
|
+
if (e && (e.type === 'user' || e.type === 'assistant')) entries.push(e);
|
|
105
|
+
} catch {
|
|
106
|
+
// partial first line of a tail read, or harness noise — skip
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (entries.length === 0) return null;
|
|
110
|
+
|
|
111
|
+
let lastPromptIdx = -1;
|
|
112
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
113
|
+
if (isRealUserPrompt(entries[i])) {
|
|
114
|
+
lastPromptIdx = i;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// No prompt boundary in the (tail) window → attribute everything we see to
|
|
119
|
+
// the current turn rather than dropping it (the tail bound already scopes us).
|
|
120
|
+
const turn = entries.slice(lastPromptIdx + 1);
|
|
121
|
+
|
|
122
|
+
const calls = []; // {id, name, summary, result}
|
|
123
|
+
const byId = new Map();
|
|
124
|
+
for (const e of turn) {
|
|
125
|
+
for (const b of contentBlocks(e.message)) {
|
|
126
|
+
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
|
127
|
+
const call = { id: b.id, name: b.name, summary: summarizeInput(b.input), result: '' };
|
|
128
|
+
calls.push(call);
|
|
129
|
+
if (typeof b.id === 'string') byId.set(b.id, call);
|
|
130
|
+
} else if (b.type === 'tool_result') {
|
|
131
|
+
const call = typeof b.tool_use_id === 'string' ? byId.get(b.tool_use_id) : undefined;
|
|
132
|
+
if (call && !call.result) {
|
|
133
|
+
call.result = oneLine(flattenResultContent(b.content), RESULT_SNIPPET_CHARS);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (calls.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
const lines = [];
|
|
141
|
+
let used = 0;
|
|
142
|
+
let shown = 0;
|
|
143
|
+
for (const call of calls) {
|
|
144
|
+
const line = `- ${call.name}(${call.summary})${call.result ? ` → ${call.result}` : ''}`;
|
|
145
|
+
if (used + line.length + 1 > BLOCK_CAP_CHARS) break;
|
|
146
|
+
lines.push(line);
|
|
147
|
+
used += line.length + 1;
|
|
148
|
+
shown += 1;
|
|
149
|
+
}
|
|
150
|
+
if (shown < calls.length) {
|
|
151
|
+
lines.push(`- …${calls.length - shown} more tool call(s) truncated`);
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Bounded tail read — a turn comfortably fits in the window; a multi-MB
|
|
157
|
+
// session file is never loaded whole inside the Stop hook's budget.
|
|
158
|
+
export function readTranscriptTail(path, maxBytes = DEFAULT_TAIL_BYTES) {
|
|
159
|
+
let fd;
|
|
160
|
+
try {
|
|
161
|
+
fd = openSync(path, 'r');
|
|
162
|
+
const size = fstatSync(fd).size;
|
|
163
|
+
const start = Math.max(0, size - maxBytes);
|
|
164
|
+
const len = size - start;
|
|
165
|
+
const buf = Buffer.alloc(len);
|
|
166
|
+
readSync(fd, buf, 0, len, start);
|
|
167
|
+
return buf.toString('utf8');
|
|
168
|
+
} catch {
|
|
169
|
+
return '';
|
|
170
|
+
} finally {
|
|
171
|
+
if (fd !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
} catch {
|
|
175
|
+
// best-effort close
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: memory-search
|
|
3
|
+
description: Searches the project's deep memory archive (claude-memory-kit) and returns a curated summary of relevant facts, decisions, and history. Use when the answer may already be recorded from a past session — "what did we decide about X", "why did we do Y", "have we seen this error before", "how did we solve this last time", "what's our convention for Z" — or before re-deriving any project knowledge, setup, or prior decision from the code. The session-start memory snapshot is a bounded hot index, not everything; this skill reaches the rest. Skip when the question is purely about current code state (use Read/Grep), about this conversation only, or the user asked to ignore memory.
|
|
4
|
+
context: fork
|
|
5
|
+
allowed-tools: mcp__cmk__mk_search mcp__cmk__mk_get mcp__cmk__mk_timeline mcp__cmk__mk_recent_activity Bash(cmk search *) Bash(cmk get *) Bash(cmk timeline *) Bash(cmk recent-activity *)
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Recalling from deep memory
|
|
9
|
+
|
|
10
|
+
You are a memory-retrieval agent. Search the kit's memory archive for: $ARGUMENTS
|
|
11
|
+
|
|
12
|
+
Query well: search the core noun phrases (e.g. "deploy target", "auth
|
|
13
|
+
library decision"), not a full sentence. If the line above carries NO query
|
|
14
|
+
(you run isolated and cannot see the conversation), start from the
|
|
15
|
+
"When the query is vague" section below instead.
|
|
16
|
+
|
|
17
|
+
Memory is the ground truth for documented knowledge and prior decisions
|
|
18
|
+
(the injected-snapshot authority rule). Your job is to find what is already
|
|
19
|
+
recorded and return ONLY a curated summary — never the raw dumps.
|
|
20
|
+
|
|
21
|
+
## The 3-step ladder (filter before you fetch)
|
|
22
|
+
|
|
23
|
+
Work index → context → bodies. Full bodies are ~10x the tokens of an index
|
|
24
|
+
line; fetch them only for the ids that survived filtering.
|
|
25
|
+
|
|
26
|
+
**Step 1 — Search the index.** Prefer the MCP tool when the `cmk` server is
|
|
27
|
+
connected; otherwise the CLI:
|
|
28
|
+
|
|
29
|
+
- MCP: `mk_search` with `query` (natural language is fine — when semantic
|
|
30
|
+
recall is enabled the project default searches by meaning; paraphrase hits).
|
|
31
|
+
- CLI: `cmk search "<query>"`
|
|
32
|
+
|
|
33
|
+
Each hit is one line: id, tier/trust, source location, snippet. Run 1-3
|
|
34
|
+
query variants if the first misses (synonyms; the key noun alone). Drop
|
|
35
|
+
hits that are clearly off-topic or too generic.
|
|
36
|
+
|
|
37
|
+
**Step 2 — Context around an anchor (optional).** When a hit looks right
|
|
38
|
+
but you need what happened around it (what led to a decision, what followed
|
|
39
|
+
a fix):
|
|
40
|
+
|
|
41
|
+
- MCP: `mk_timeline` with `anchor: "<id>"` (and `depth_before`/`depth_after`).
|
|
42
|
+
- CLI: `cmk timeline <id>`
|
|
43
|
+
|
|
44
|
+
**Step 3 — Fetch full bodies for the survivors only.**
|
|
45
|
+
|
|
46
|
+
- MCP: `mk_get` with `ids: [...]` — batch all survivors in ONE call.
|
|
47
|
+
- CLI: `cmk get <id> <id> ...`
|
|
48
|
+
|
|
49
|
+
Rich facts carry **Why** / **How to apply** blocks — include those when the
|
|
50
|
+
question is about rationale or how to act on a rule.
|
|
51
|
+
|
|
52
|
+
**Step 4 — LAST RESORT: the session record.** Only when curated memory
|
|
53
|
+
(steps 1-3) has no answer and the question is about what actually happened
|
|
54
|
+
in a past session (an exact error message, the command that fixed
|
|
55
|
+
something, how a discussion went). This scope covers the verbatim
|
|
56
|
+
transcripts AND the compressed session summaries:
|
|
57
|
+
|
|
58
|
+
- MCP: `mk_search` with `scope: "transcripts"`.
|
|
59
|
+
- CLI: `cmk search "<query>" --scope transcripts`
|
|
60
|
+
|
|
61
|
+
Hits are raw turn excerpts (dialogue + the tools the agent ran), keyed
|
|
62
|
+
`T:<file>:<line>` — quote the relevant fragment in your summary; never dump
|
|
63
|
+
whole turns. If something found here is durably useful, say so in the
|
|
64
|
+
summary so the caller can capture it as a proper fact.
|
|
65
|
+
|
|
66
|
+
## When the query is vague
|
|
67
|
+
|
|
68
|
+
If you cannot form a concrete query, look at recent activity first, then
|
|
69
|
+
search the topic that stands out:
|
|
70
|
+
|
|
71
|
+
- MCP: `mk_recent_activity` (window `7d`) · CLI: `cmk recent-activity --window 7d`
|
|
72
|
+
|
|
73
|
+
## Output
|
|
74
|
+
|
|
75
|
+
Return a short, curated answer for the main conversation:
|
|
76
|
+
|
|
77
|
+
- The relevant facts/decisions, each with its citation id (e.g. `P-XXXXXXXX`)
|
|
78
|
+
and the Why when it matters.
|
|
79
|
+
- One line of source traceability per item (the source file the index line
|
|
80
|
+
showed).
|
|
81
|
+
- If nothing relevant exists, say exactly that — "no recorded memory on
|
|
82
|
+
this" — so the caller knows to derive it fresh and capture it afterward.
|
|
83
|
+
|
|
84
|
+
Never paste full fact files or long bodies into the summary; condense.
|
|
85
|
+
This skill is read-only — capturing new facts is the `memory-write` skill's
|
|
86
|
+
job.
|
|
@@ -34,6 +34,8 @@ The snapshot injected at session start is a **bounded hot index, not everything*
|
|
|
34
34
|
|
|
35
35
|
Reach for these *first* — re-deriving an answer the project already recorded (by re-reading files, re-searching, or working it out again) wastes the memory that exists precisely so you don't have to. Recall from memory first, then verify against the source if needed.
|
|
36
36
|
|
|
37
|
+
**Authority rule:** when injected memory contradicts your assumptions, injected memory wins — it is the ground truth for documented knowledge and prior decisions (terminal/tool output stays the ground truth for live system state; official docs for version-specifics). Never treat a question as novel when the answer is already in your prompt.
|
|
38
|
+
|
|
37
39
|
### Memory write rules (for Claude)
|
|
38
40
|
|
|
39
41
|
Most capture is automatic — the Stop hook extracts durable facts each turn, no action needed. To capture something **explicitly**, the **`memory-write` skill** carries the full procedure; it loads on demand when you save a fact. The invariants it enforces:
|