@lh8ppl/claude-memory-kit 0.2.3 → 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/doctor.mjs +52 -125
- 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 +2 -2
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +100 -12
- package/src/semantic-backend.mjs +485 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/spawn-bin.mjs +7 -2
- package/src/subcommands.mjs +95 -18
- 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/template/support/cron-jobs/nightly-memsearch-index.md +0 -17
- package/template/support/milvus-deploy/README.md +0 -57
- package/template/support/milvus-deploy/docker-compose.yml +0 -66
- package/template/support/scripts/memsearch-index-with-flush.sh +0 -59
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` +
|
|
@@ -249,10 +290,12 @@ function runLessonsPromote(id, options = {}) {
|
|
|
249
290
|
/**
|
|
250
291
|
* `cmk search` — Task 30. Hybrid keyword + optional semantic.
|
|
251
292
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
293
|
+
* The keyword backend (FTS5 BM25 over the observations index) always
|
|
294
|
+
* ships. Semantic + hybrid modes require the Layer-5b semantic backend,
|
|
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
|
|
298
|
+
* is the drop-in point for the future backend.
|
|
256
299
|
*
|
|
257
300
|
* Filter flags (per tasks.md 30.4):
|
|
258
301
|
* --mode <keyword|semantic|hybrid> (default keyword)
|
|
@@ -262,7 +305,7 @@ function runLessonsPromote(id, options = {}) {
|
|
|
262
305
|
* --limit <N> (default 20)
|
|
263
306
|
* --include-tombstoned (default false)
|
|
264
307
|
*/
|
|
265
|
-
function runSearch(queryParts, options) {
|
|
308
|
+
async function runSearch(queryParts, options) {
|
|
266
309
|
const projectRoot = resolvePath(process.cwd());
|
|
267
310
|
const userDir =
|
|
268
311
|
process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
@@ -286,15 +329,51 @@ function runSearch(queryParts, options) {
|
|
|
286
329
|
'searching the existing index. Run `cmk reindex --full` if results look stale.',
|
|
287
330
|
);
|
|
288
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
|
+
}
|
|
289
366
|
const r = searchAction({
|
|
290
367
|
db,
|
|
291
368
|
query,
|
|
292
|
-
mode
|
|
369
|
+
mode,
|
|
370
|
+
scope,
|
|
293
371
|
minTrust: options?.minTrust,
|
|
294
372
|
tier: options?.tier,
|
|
295
373
|
since: options?.since,
|
|
296
374
|
limit: options?.limit !== undefined ? Number(options.limit) : undefined,
|
|
297
375
|
includeTombstoned: options?.includeTombstoned === true,
|
|
376
|
+
semanticBackend,
|
|
298
377
|
});
|
|
299
378
|
if (r.action === 'error') {
|
|
300
379
|
for (const e of r.errors) console.error(`cmk search: ${e}`);
|
|
@@ -310,13 +389,15 @@ function runSearch(queryParts, options) {
|
|
|
310
389
|
for (const hit of r.results) {
|
|
311
390
|
// Plain-text output suitable for terminal piping. Snippet uses
|
|
312
391
|
// FTS5's <b>...</b> markers; preserved as-is so callers can pipe
|
|
313
|
-
// 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';
|
|
314
395
|
console.log(
|
|
315
|
-
`${hit.id}\t${
|
|
396
|
+
`${hit.id}\t${provenance}\t${hit.source_file}:${hit.source_line}\t${hit.snippet}`,
|
|
316
397
|
);
|
|
317
398
|
}
|
|
318
399
|
console.log(
|
|
319
|
-
`\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}` : ''})`,
|
|
320
401
|
);
|
|
321
402
|
} finally {
|
|
322
403
|
db.close();
|
|
@@ -1610,6 +1691,8 @@ export const subcommands = [
|
|
|
1610
1691
|
optionSpec: [
|
|
1611
1692
|
{ flags: '--force', description: 'allow downgrade of an existing newer-version CLAUDE.md block' },
|
|
1612
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)' },
|
|
1613
1696
|
{ flags: '--verbose', description: 'show the per-tier created/skipped file breakdown' },
|
|
1614
1697
|
],
|
|
1615
1698
|
action: runInstall,
|
|
@@ -1652,7 +1735,8 @@ export const subcommands = [
|
|
|
1652
1735
|
milestone: 30,
|
|
1653
1736
|
argSpec: [{ flags: '<query...>', description: 'query terms' }],
|
|
1654
1737
|
optionSpec: [
|
|
1655
|
-
{ 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)' },
|
|
1656
1740
|
{ flags: '--min-trust <level>', description: 'low | medium | high' },
|
|
1657
1741
|
{ flags: '--tier <tier>', description: 'U | P | L (filter to a single tier)' },
|
|
1658
1742
|
{ flags: '--since <date>', description: 'ISO date — exclude observations older than this' },
|
|
@@ -1736,13 +1820,6 @@ export const subcommands = [
|
|
|
1736
1820
|
],
|
|
1737
1821
|
action: stub('config', 'v0.1.x'),
|
|
1738
1822
|
},
|
|
1739
|
-
{
|
|
1740
|
-
name: 'view',
|
|
1741
|
-
description: 'open a local markdown viewer at 127.0.0.1:37778',
|
|
1742
|
-
milestone: 'v0.1.x',
|
|
1743
|
-
optionSpec: [{ flags: '--port <n>', description: 'override default port 37778' }],
|
|
1744
|
-
action: stub('view', 'v0.1.x'),
|
|
1745
|
-
},
|
|
1746
1823
|
{
|
|
1747
1824
|
name: 'import-anthropic-memory',
|
|
1748
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:
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: Nightly MemSearch Index
|
|
3
|
-
time: '02:00'
|
|
4
|
-
days: daily
|
|
5
|
-
active: 'true'
|
|
6
|
-
description: 'Re-indexes context/ markdown files for vector search'
|
|
7
|
-
timeout: 10m
|
|
8
|
-
job_type: shell_command
|
|
9
|
-
command: 'bash scripts/memsearch-index-with-flush.sh context/memory context/sessions context/transcripts'
|
|
10
|
-
working_directory: '${CLAUDE_PROJECT_DIR}'
|
|
11
|
-
# Requires Layer 5 installed (memsearch on PATH + a reachable Milvus backend).
|
|
12
|
-
# On Windows: Docker Desktop running with Milvus container — see context/SETUP.md.
|
|
13
|
-
# If memsearch isn't installed or backend isn't reachable, the task will fail;
|
|
14
|
-
# HC-7 (backend reachability) will flag it on the next session start.
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
Updates the memsearch vector index with any new content from `context/memory/`, `context/sessions/`, and `context/transcripts/`. Idempotent: only re-embeds chunks whose hash changed since the last run.
|