@lh8ppl/claude-memory-kit 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-drain.mjs +17 -1
- package/src/auto-extract.mjs +4 -5
- package/src/auto-persona.mjs +86 -1
- package/src/capture-prompt.mjs +2 -1
- package/src/config-core.mjs +161 -0
- package/src/conflict-queue.mjs +2 -2
- package/src/content-hash.mjs +30 -0
- package/src/doctor.mjs +62 -3
- package/src/import-anthropic-memory.mjs +2 -2
- package/src/import-claude-md.mjs +333 -0
- package/src/index-rebuild.mjs +6 -2
- package/src/index.mjs +10 -0
- package/src/inject-context.mjs +130 -1
- package/src/install.mjs +75 -2
- package/src/mcp-server.mjs +6 -1
- package/src/memory-health.mjs +229 -0
- package/src/memory-write.mjs +32 -10
- package/src/native-binding.mjs +142 -0
- package/src/poison-guard.mjs +55 -0
- package/src/remember-core.mjs +53 -8
- package/src/repair.mjs +20 -3
- package/src/semantic-backend.mjs +114 -0
- package/src/subcommands.mjs +268 -27
- package/src/transcript-index.mjs +5 -2
- package/src/write-fact.mjs +34 -3
- package/template/.claude/skills/memory-search/SKILL.md +1 -1
- package/template/.gitattributes.fragment +16 -0
- package/template/CLAUDE.md.template +1 -1
package/src/semantic-backend.mjs
CHANGED
|
@@ -399,6 +399,120 @@ export function resolveDefaultSearchMode({ projectRoot }) {
|
|
|
399
399
|
* surprise on the user's first search. Best-effort — failure reports a
|
|
400
400
|
* reason, never throws.
|
|
401
401
|
*/
|
|
402
|
+
/**
|
|
403
|
+
* The near-dup threshold for bge-base cosine — MEASURED, not assumed
|
|
404
|
+
* (live bake 2026-06-13, real Xenova/bge-base-en-v1.5 q8):
|
|
405
|
+
* must-catch paraphrases: 0.85 ("use uv not pip" pair) · 0.96 · 0.81
|
|
406
|
+
* must-NOT-catch (same domain, different facts): 0.66 · 0.64
|
|
407
|
+
* 0.78 splits the gap with ≥0.03 margin on the catch side and ≥0.12 on the
|
|
408
|
+
* miss side; q8 quantization flutters scores ±0.003 across processes, so a
|
|
409
|
+
* threshold inside the gap matters. The pre-143 DEFAULT_SEMANTIC_THRESHOLD
|
|
410
|
+
* (0.85, conflict-queue.mjs) predates the real embedder and would MISS the
|
|
411
|
+
* task's own canonical example (0.8493 < 0.85) — caught by the live test.
|
|
412
|
+
*/
|
|
413
|
+
export const SEMANTIC_NEARDUP_THRESHOLD = 0.78;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build a write-time semantic similarity function (Task 143, D-130).
|
|
417
|
+
*
|
|
418
|
+
* For the EXPLICIT capture paths (cmk remember / mk_remember): embeds the
|
|
419
|
+
* INCOMING text once (the only async model call), then returns a SYNC
|
|
420
|
+
* `similarityFn(newText, existingText)` compatible with detectConflicts'
|
|
421
|
+
* injectable seam:
|
|
422
|
+
* - candidate vector found in the content-addressed embedding cache
|
|
423
|
+
* (sha256(model\ntext) — the same key syncSemanticIndex writes) →
|
|
424
|
+
* cosine (vectors are normalized, so a dot product);
|
|
425
|
+
* - cache miss (a bullet captured since the last reindex) → token-Jaccard
|
|
426
|
+
* fallback FOR THAT PAIR — honest literal comparison, never a throw,
|
|
427
|
+
* never a per-pair model call (budget: one embed per capture, total).
|
|
428
|
+
*
|
|
429
|
+
* Not-ok states ({ok:false, reason}) let callers degrade silently to the
|
|
430
|
+
* literal pipeline (the spec's graceful-degradation contract):
|
|
431
|
+
* 'embedder-not-installed' — the optional embedder is absent.
|
|
432
|
+
* 'embed-failed: …' — the model errored on the incoming text.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} opts
|
|
435
|
+
* @param {string} opts.projectRoot
|
|
436
|
+
* @param {string} opts.newText - the incoming capture.
|
|
437
|
+
* @param {string} [opts.modelId]
|
|
438
|
+
* @param {Function} [opts.extractorImpl] - test seam: async () => extractor|null
|
|
439
|
+
* (the loadExtractor shape).
|
|
440
|
+
* @param {Function} [opts.cacheLookupImpl] - test seam: (text) => number[]|null.
|
|
441
|
+
* @returns {Promise<{ok:true, similarityFn:Function, backend:'semantic'} | {ok:false, reason:string}>}
|
|
442
|
+
*/
|
|
443
|
+
export async function prepareSemanticSimilarity({
|
|
444
|
+
projectRoot,
|
|
445
|
+
newText,
|
|
446
|
+
modelId = DEFAULT_MODEL_ID,
|
|
447
|
+
extractorImpl,
|
|
448
|
+
cacheLookupImpl,
|
|
449
|
+
} = {}) {
|
|
450
|
+
// Honor the global semantic kill-switch (consistency with
|
|
451
|
+
// prepareSemanticBackend) — the near-dup guard degrades to {} just like
|
|
452
|
+
// search degrades to keyword. Skipped when a test injects an extractor.
|
|
453
|
+
if (!extractorImpl && process.env.CMK_DISABLE_SEMANTIC === '1') {
|
|
454
|
+
return { ok: false, reason: 'embedder-disabled' };
|
|
455
|
+
}
|
|
456
|
+
const load = extractorImpl ?? (() => loadExtractor(modelId));
|
|
457
|
+
const extractor = await load();
|
|
458
|
+
if (!extractor) return { ok: false, reason: 'embedder-not-installed' };
|
|
459
|
+
|
|
460
|
+
let newVec;
|
|
461
|
+
try {
|
|
462
|
+
const out = await extractor(newText, { pooling: 'mean', normalize: true });
|
|
463
|
+
newVec = (out.tolist())[0] ?? out.tolist();
|
|
464
|
+
// Single-text extractor output is [[...]]; the fake seam may return [...].
|
|
465
|
+
if (Array.isArray(newVec[0])) newVec = newVec[0];
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return { ok: false, reason: `embed-failed: ${err?.message ?? err}` };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Candidate lookup: SNAPSHOT the embedding cache up front and CLOSE the
|
|
471
|
+
// connection immediately — the returned similarityFn's lifetime is the
|
|
472
|
+
// caller's business, and a connection held in the closure would leak one
|
|
473
|
+
// db handle per capture inside the long-running MCP server (skill-review
|
|
474
|
+
// blocking finding). Size is fine: 768 floats × 4B ≈ 3KB/row. A missing /
|
|
475
|
+
// schema-less db (semantic never synced) degrades every pair to Jaccard.
|
|
476
|
+
let lookup = cacheLookupImpl;
|
|
477
|
+
if (!lookup) {
|
|
478
|
+
let bySha = null;
|
|
479
|
+
try {
|
|
480
|
+
const { openIndexDb } = await import('./index-db.mjs');
|
|
481
|
+
const db = openIndexDb({ projectRoot });
|
|
482
|
+
try {
|
|
483
|
+
bySha = new Map();
|
|
484
|
+
for (const row of db.prepare('SELECT content_sha, vector FROM embedding_cache WHERE model = ?').all(modelId)) {
|
|
485
|
+
bySha.set(
|
|
486
|
+
row.content_sha,
|
|
487
|
+
Array.from(new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4)),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
} finally {
|
|
491
|
+
db.close();
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
bySha = null;
|
|
495
|
+
}
|
|
496
|
+
lookup = bySha ? (text) => bySha.get(sha256(`${modelId}\n${text}`)) ?? null : () => null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const { tokenJaccardSimilarity } = await import('./conflict-queue.mjs');
|
|
500
|
+
const similarityFn = (a, b) => {
|
|
501
|
+
try {
|
|
502
|
+
const candidate = lookup(b);
|
|
503
|
+
if (!candidate || candidate.length !== newVec.length) {
|
|
504
|
+
return tokenJaccardSimilarity(a, b);
|
|
505
|
+
}
|
|
506
|
+
let dot = 0;
|
|
507
|
+
for (let i = 0; i < newVec.length; i++) dot += newVec[i] * candidate[i];
|
|
508
|
+
return dot; // normalized vectors → dot IS cosine
|
|
509
|
+
} catch {
|
|
510
|
+
return tokenJaccardSimilarity(a, b);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
return { ok: true, similarityFn, backend: 'semantic' };
|
|
514
|
+
}
|
|
515
|
+
|
|
402
516
|
export async function warmEmbedder({ modelId = DEFAULT_MODEL_ID } = {}) {
|
|
403
517
|
const t0 = Date.now();
|
|
404
518
|
try {
|
package/src/subcommands.mjs
CHANGED
|
@@ -27,12 +27,14 @@ import { weeklyCurate } from './weekly-curate.mjs';
|
|
|
27
27
|
import { autoPersona } from './auto-persona.mjs';
|
|
28
28
|
import { exportPersona, importPersona } from './persona-portability.mjs';
|
|
29
29
|
import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
|
|
30
|
-
import { rememberRich, richFactTitle, nonProjectTierNote } from './remember-core.mjs';
|
|
30
|
+
import { rememberRich, richFactTitle, nonProjectTierNote, prepareNearDupGuard } from './remember-core.mjs';
|
|
31
31
|
import { getObservations, citeLink, buildTimeline, recentActivity } from './read-core.mjs';
|
|
32
32
|
import { readHookStdin } from './read-hook-stdin.mjs';
|
|
33
33
|
import { runLazyCompress } from './lazy-compress.mjs';
|
|
34
34
|
import { runDoctor } from './doctor.mjs';
|
|
35
35
|
import { importAnthropicMemory } from './import-anthropic-memory.mjs';
|
|
36
|
+
import { configGet, configSet, configShowOrigin } from './config-core.mjs';
|
|
37
|
+
import { importClaudeMd } from './import-claude-md.mjs';
|
|
36
38
|
import { extractTranscript, discoverSessions } from './transcripts.mjs';
|
|
37
39
|
import { runRepair } from './repair.mjs';
|
|
38
40
|
import { runRoll, ROLL_SCOPES } from './roll.mjs';
|
|
@@ -60,6 +62,8 @@ import { overrideTrust as overrideTrustAction } from './trust.mjs';
|
|
|
60
62
|
import { resolveConflictQueue, mergeScratchpadBullets } from './conflict-queue.mjs';
|
|
61
63
|
import { resolveReviewQueue } from './review-queue.mjs';
|
|
62
64
|
import { createInterface } from 'node:readline';
|
|
65
|
+
import { spawnSync } from 'node:child_process';
|
|
66
|
+
import { checkKitBinding } from './native-binding.mjs';
|
|
63
67
|
import { resolve as resolvePath, join, basename } from 'node:path';
|
|
64
68
|
|
|
65
69
|
const NOTICE_PREFIX = 'not yet implemented';
|
|
@@ -97,7 +101,71 @@ export function formatSemanticSummary(semantic, { noHooks = false } = {}) {
|
|
|
97
101
|
* summary, and reports the CLAUDE.md action (created / appended /
|
|
98
102
|
* replaced / upgraded / downgrade-blocked / forced-downgrade / unchanged).
|
|
99
103
|
*/
|
|
100
|
-
|
|
104
|
+
// Task 141a (D-129): the install-time binding ask. npm 12 blocks
|
|
105
|
+
// better-sqlite3's binding build on a fresh `npm install -g` — the user's
|
|
106
|
+
// 2026-06-12 steer: ask AT INSTALL, never leave it to a secondary command.
|
|
107
|
+
// Interactive consent is required because the fix is itself an
|
|
108
|
+
// `npm install -g` (the design §14 ask-before-install rule); non-interactive
|
|
109
|
+
// runs print the command instead. All deps injectable for tests.
|
|
110
|
+
async function offerBindingFix(nativeBinding, options, { log, logError }) {
|
|
111
|
+
if (!nativeBinding || nativeBinding.ok) return;
|
|
112
|
+
const remedy = nativeBinding.remedy;
|
|
113
|
+
logError(
|
|
114
|
+
` warning: better-sqlite3's native binding is unavailable (${nativeBinding.reason}).`,
|
|
115
|
+
);
|
|
116
|
+
logError(
|
|
117
|
+
' Most common cause: npm 12 blocks dependency install scripts by default (a Node major upgrade is the other). Search/reindex cannot work until the binding is rebuilt.',
|
|
118
|
+
);
|
|
119
|
+
// An explicit askImpl implies a consent channel exists (the test seam /
|
|
120
|
+
// programmatic caller); only the readline default needs a real TTY.
|
|
121
|
+
const interactive =
|
|
122
|
+
options?.interactive ?? (options?.askImpl ? true : process.stdin.isTTY === true);
|
|
123
|
+
const askFn =
|
|
124
|
+
options?.askImpl ??
|
|
125
|
+
(interactive
|
|
126
|
+
? (question) =>
|
|
127
|
+
new Promise((resolveAnswer) => {
|
|
128
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
129
|
+
rl.question(question, (answer) => {
|
|
130
|
+
rl.close();
|
|
131
|
+
resolveAnswer(answer);
|
|
132
|
+
});
|
|
133
|
+
})
|
|
134
|
+
: null);
|
|
135
|
+
if (!interactive || !askFn) {
|
|
136
|
+
logError(` Fix it any time with: ${remedy}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const answer = String(await askFn(` Fix it now by running \`${remedy}\`? [Y/n] `))
|
|
140
|
+
.trim()
|
|
141
|
+
.toLowerCase();
|
|
142
|
+
const yes = answer === '' || answer === 'y' || answer === 'yes';
|
|
143
|
+
if (!yes) {
|
|
144
|
+
log(` Skipped. Fix it any time with: ${remedy}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const fixRunner =
|
|
148
|
+
options?.fixRunner ??
|
|
149
|
+
((cmd) =>
|
|
150
|
+
// Constant command under shell:true (npm is npm.cmd on Windows); the
|
|
151
|
+
// 10-min ceiling mirrors buildDefaultNpmRunner's spawn discipline.
|
|
152
|
+
spawnSync(cmd, { stdio: 'inherit', shell: true, timeout: 600_000 }));
|
|
153
|
+
const r = fixRunner(remedy);
|
|
154
|
+
const reProbe = options?.reProbe ?? checkKitBinding;
|
|
155
|
+
const after = r.status === 0 ? reProbe() : { ok: false };
|
|
156
|
+
if (after.ok) {
|
|
157
|
+
log(' Binding rebuilt — search is ready.');
|
|
158
|
+
} else {
|
|
159
|
+
logError(` The binding is still unavailable — run it manually later: ${remedy}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Exported for tests (Task 141a) — dep-injectable (cwd / userTier / log /
|
|
164
|
+
// logError / bindingProbe / askImpl / fixRunner / reProbe / interactive) on
|
|
165
|
+
// the runImportClaudeMd pattern. Defaults unchanged for production.
|
|
166
|
+
export async function runInstall(options /* , command */) {
|
|
167
|
+
const log = options?.log ?? console.log;
|
|
168
|
+
const logError = options?.logError ?? console.error;
|
|
101
169
|
// commander maps `--no-hooks` to options.hooks === false.
|
|
102
170
|
const noHooks = !!(options && options.hooks === false);
|
|
103
171
|
const verbose = !!(options && options.verbose);
|
|
@@ -110,6 +178,9 @@ async function runInstall(options /* , command */) {
|
|
|
110
178
|
// to options.withSemantic.
|
|
111
179
|
withSemantic: !!(options && options.withSemantic),
|
|
112
180
|
noSemantic: !!(options && options.semantic === false),
|
|
181
|
+
projectRoot: options?.cwd,
|
|
182
|
+
userTier: options?.userTier,
|
|
183
|
+
bindingProbe: options?.bindingProbe,
|
|
113
184
|
});
|
|
114
185
|
|
|
115
186
|
// Outcome over inventory (self-test UX finding): state the resulting state +
|
|
@@ -117,7 +188,7 @@ async function runInstall(options /* , command */) {
|
|
|
117
188
|
// read like a problem on a FRESH folder — the "skipped" are the cross-project
|
|
118
189
|
// user tier at ~/.claude-memory-kit/ (OUTSIDE this folder), already on disk.
|
|
119
190
|
// The full per-tier breakdown is --verbose only.
|
|
120
|
-
const projectName = basename(
|
|
191
|
+
const projectName = basename(result.projectRoot);
|
|
121
192
|
const wired =
|
|
122
193
|
result.hooks.action === 'wired' || result.hooks.action === 'unchanged';
|
|
123
194
|
const broughtSomethingNew =
|
|
@@ -126,20 +197,20 @@ async function runInstall(options /* , command */) {
|
|
|
126
197
|
result.claudeMd.action === 'created';
|
|
127
198
|
|
|
128
199
|
if (broughtSomethingNew) {
|
|
129
|
-
|
|
200
|
+
log(
|
|
130
201
|
`cmk install: ${projectName} ready — context/ scaffolded${
|
|
131
202
|
wired ? ', hooks wired' : ''
|
|
132
203
|
}.`,
|
|
133
204
|
);
|
|
134
205
|
} else {
|
|
135
|
-
|
|
206
|
+
log(
|
|
136
207
|
`cmk install: ${projectName} already set up (your edits preserved)${
|
|
137
208
|
wired ? ', hooks refreshed' : ''
|
|
138
209
|
}.`,
|
|
139
210
|
);
|
|
140
211
|
}
|
|
141
212
|
if (wired) {
|
|
142
|
-
|
|
213
|
+
log(
|
|
143
214
|
' Restart Claude Code to activate. Complete install — no separate /plugin step needed.',
|
|
144
215
|
);
|
|
145
216
|
}
|
|
@@ -147,35 +218,39 @@ async function runInstall(options /* , command */) {
|
|
|
147
218
|
// Auto Memory by default; surface the one-command opt-out (null when already
|
|
148
219
|
// opted out, so we don't nag).
|
|
149
220
|
const nativeNote = nativeMemoryInstallNote(result.projectRoot);
|
|
150
|
-
if (nativeNote)
|
|
221
|
+
if (nativeNote) log(nativeNote);
|
|
151
222
|
// Task 46: semantic-recall outcome (pure formatter, Task 125.4 — testable
|
|
152
223
|
// without spawning install; the error case returns null because enableSemantic
|
|
153
224
|
// errors already land in result.errors and print through the error path).
|
|
154
225
|
const semanticLine = formatSemanticSummary(result.semantic, { noHooks });
|
|
155
|
-
if (semanticLine)
|
|
226
|
+
if (semanticLine) log(semanticLine);
|
|
156
227
|
if (verbose) {
|
|
157
|
-
|
|
228
|
+
log(
|
|
158
229
|
` files: ${result.created.length} created, ${result.skipped.length} already present` +
|
|
159
230
|
(result.skipped.length
|
|
160
231
|
? ' (incl. the cross-project user tier at ~/.claude-memory-kit/, outside this folder)'
|
|
161
232
|
: ''),
|
|
162
233
|
);
|
|
163
|
-
|
|
234
|
+
log(
|
|
164
235
|
` .gitignore=${result.gitignore.action} · CLAUDE.md=${result.claudeMd.action} · hooks=${result.hooks.action}`,
|
|
165
236
|
);
|
|
166
237
|
}
|
|
167
238
|
|
|
168
239
|
if (result.claudeMd.action === 'downgrade-blocked') {
|
|
169
|
-
|
|
240
|
+
logError(
|
|
170
241
|
` warning: CLAUDE.md already has a newer kit block (v${result.claudeMd.oldVersion}). ` +
|
|
171
242
|
`Re-run with --force to downgrade.`
|
|
172
243
|
);
|
|
173
244
|
}
|
|
174
245
|
|
|
175
246
|
if (result.errors.length > 0) {
|
|
176
|
-
for (const e of result.errors)
|
|
247
|
+
for (const e of result.errors) logError(` error: ${e.path}: ${e.error}`);
|
|
177
248
|
process.exitCode = 1;
|
|
178
249
|
}
|
|
250
|
+
|
|
251
|
+
// Task 141a: the binding ask comes LAST — it's the one thing the user may
|
|
252
|
+
// still need to act on, and the tail of install output is what gets read.
|
|
253
|
+
await offerBindingFix(result.nativeBinding, options, { log, logError });
|
|
179
254
|
}
|
|
180
255
|
|
|
181
256
|
/**
|
|
@@ -656,7 +731,10 @@ export function parseFactInput(options, { readFile, readStdin } = {}) {
|
|
|
656
731
|
};
|
|
657
732
|
}
|
|
658
733
|
|
|
659
|
-
|
|
734
|
+
// Task 143: async since the near-dup guard may embed the incoming text
|
|
735
|
+
// (one model call, explicit path only). Commander awaits actions; the
|
|
736
|
+
// terse-path tests were updated to await (contract change, intent preserved).
|
|
737
|
+
export async function runRemember(textParts, options, deps = {}) {
|
|
660
738
|
const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
|
|
661
739
|
const userDir =
|
|
662
740
|
deps.userDir ?? process.env.MEMORY_KIT_USER_DIR ?? join(homedir(), '.claude-memory-kit');
|
|
@@ -717,6 +795,10 @@ export function runRemember(textParts, options, deps = {}) {
|
|
|
717
795
|
const tier = 'P';
|
|
718
796
|
const trust = options?.trust ?? 'high';
|
|
719
797
|
const section = options?.section ?? 'Active Threads';
|
|
798
|
+
// Task 143 (D-130): semantic near-dup guard — extra opts only when this
|
|
799
|
+
// project is semantic-configured and the embedder is available; {} keeps
|
|
800
|
+
// the literal pipeline (graceful degradation, never blocks capture).
|
|
801
|
+
const nearDup = await prepareNearDupGuard({ projectRoot, text, ...(deps.nearDupGuard ? { prepareImpl: deps.nearDupGuard.prepareImpl, resolveModeImpl: deps.nearDupGuard.resolveModeImpl } : {}) });
|
|
720
802
|
const r = memoryWrite({
|
|
721
803
|
action: 'add',
|
|
722
804
|
text,
|
|
@@ -727,22 +809,23 @@ export function runRemember(textParts, options, deps = {}) {
|
|
|
727
809
|
source: 'user-explicit',
|
|
728
810
|
projectRoot,
|
|
729
811
|
userDir,
|
|
812
|
+
...nearDup,
|
|
730
813
|
});
|
|
731
814
|
if (r.action === 'error') {
|
|
732
815
|
for (const e of r.errors ?? [`error (${r.errorCategory})`]) {
|
|
733
|
-
|
|
816
|
+
logError(`cmk remember: ${e}`);
|
|
734
817
|
}
|
|
735
818
|
process.exitCode = 2;
|
|
736
819
|
return;
|
|
737
820
|
}
|
|
738
821
|
if (r.action === 'queued') {
|
|
739
|
-
|
|
740
|
-
`cmk remember: queued for review — a higher-trust fact already covers this. ` +
|
|
822
|
+
log(
|
|
823
|
+
`cmk remember: queued for review — a similar or higher-trust fact already covers this. ` +
|
|
741
824
|
`Resolve with \`cmk queue conflicts\` (${r.path}).`,
|
|
742
825
|
);
|
|
743
826
|
return;
|
|
744
827
|
}
|
|
745
|
-
|
|
828
|
+
log(
|
|
746
829
|
`cmk remember: saved to P/MEMORY.md (${section})${r.id ? ` [${r.id}]` : ''}`,
|
|
747
830
|
);
|
|
748
831
|
}
|
|
@@ -1183,12 +1266,93 @@ async function runDoctorCli(/* options */) {
|
|
|
1183
1266
|
`Summary: ${counts.pass} pass · ${counts.fail} fail · ${counts.skip} skip (${r.duration_ms}ms)`,
|
|
1184
1267
|
);
|
|
1185
1268
|
if (counts.fail > 0) process.exitCode = 1;
|
|
1269
|
+
|
|
1270
|
+
// Task 144 (D-130): the memory-HEALTH section — content quality, not
|
|
1271
|
+
// plumbing. Informational only: read-only, never changes the exit code,
|
|
1272
|
+
// best-effort (a content-stat hiccup must not fail a healthy doctor).
|
|
1273
|
+
try {
|
|
1274
|
+
const { analyzeMemoryHealth, formatMemoryHealth } = await import('./memory-health.mjs');
|
|
1275
|
+
console.log('');
|
|
1276
|
+
console.log(formatMemoryHealth(analyzeMemoryHealth({ projectRoot })));
|
|
1277
|
+
} catch {
|
|
1278
|
+
// informational section only — stay silent on failure
|
|
1279
|
+
}
|
|
1186
1280
|
} catch (err) {
|
|
1187
1281
|
console.error(`cmk doctor: unexpected error: ${err?.message ?? err}`);
|
|
1188
1282
|
process.exitCode = 2;
|
|
1189
1283
|
}
|
|
1190
1284
|
}
|
|
1191
1285
|
|
|
1286
|
+
// Task 129 (D-121): `cmk config` — real, replacing the v0.1.0 stub. Dotted-key
|
|
1287
|
+
// get/set/--show-origin over the per-tier settings.json files. Dep-injectable
|
|
1288
|
+
// (cwd/userDir/log/logError) on the runImportClaudeMd pattern for testing the
|
|
1289
|
+
// CLI surface. The pure resolution/mutation lives in config-core.mjs.
|
|
1290
|
+
const TIER_FLAG_TO_NAME = { local: 'local', project: 'project', user: 'user' };
|
|
1291
|
+
|
|
1292
|
+
export function runConfigGet(key, options = {}) {
|
|
1293
|
+
const projectRoot = options?.cwd ?? resolvePath(process.cwd());
|
|
1294
|
+
const userDir = options?.userDir ?? join(homedir(), '.claude-memory-kit');
|
|
1295
|
+
const log = options?.log ?? console.log;
|
|
1296
|
+
const logError = options?.logError ?? console.error;
|
|
1297
|
+
const r = configGet(key, { projectRoot, userDir });
|
|
1298
|
+
if (!r.found) {
|
|
1299
|
+
logError(`cmk config get: '${key}' is not set in any tier`);
|
|
1300
|
+
process.exitCode = 2;
|
|
1301
|
+
return r;
|
|
1302
|
+
}
|
|
1303
|
+
log(typeof r.value === 'string' ? r.value : JSON.stringify(r.value));
|
|
1304
|
+
return r;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
export function runConfigSet(key, value, options = {}) {
|
|
1308
|
+
const projectRoot = options?.cwd ?? resolvePath(process.cwd());
|
|
1309
|
+
const userDir = options?.userDir ?? join(homedir(), '.claude-memory-kit');
|
|
1310
|
+
const log = options?.log ?? console.log;
|
|
1311
|
+
const logError = options?.logError ?? console.error;
|
|
1312
|
+
const tier = TIER_FLAG_TO_NAME[options?.tier ?? 'project'] ?? 'project';
|
|
1313
|
+
const r = configSet(key, value, { projectRoot, userDir, tier });
|
|
1314
|
+
if (!r.ok) {
|
|
1315
|
+
logError(`cmk config set: ${r.error}`);
|
|
1316
|
+
process.exitCode = 2;
|
|
1317
|
+
return r;
|
|
1318
|
+
}
|
|
1319
|
+
log(`cmk config set: ${key} = ${value} (${r.tier} tier)`);
|
|
1320
|
+
return r;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
export function runConfigShowOrigin(key, options = {}) {
|
|
1324
|
+
const projectRoot = options?.cwd ?? resolvePath(process.cwd());
|
|
1325
|
+
const userDir = options?.userDir ?? join(homedir(), '.claude-memory-kit');
|
|
1326
|
+
const log = options?.log ?? console.log;
|
|
1327
|
+
const logError = options?.logError ?? console.error;
|
|
1328
|
+
const r = configShowOrigin(key, { projectRoot, userDir });
|
|
1329
|
+
if (!r.found) {
|
|
1330
|
+
logError(`cmk config --show-origin: '${key}' is not set in any tier`);
|
|
1331
|
+
process.exitCode = 2;
|
|
1332
|
+
return r;
|
|
1333
|
+
}
|
|
1334
|
+
for (const e of r.entries) {
|
|
1335
|
+
const val = typeof e.value === 'string' ? `"${e.value}"` : JSON.stringify(e.value);
|
|
1336
|
+
const note = e.winner ? '' : ` (shadowed by ${e.shadowedBy})`;
|
|
1337
|
+
log(`${e.tier.padEnd(8)} ${e.path} ${val}${note}`);
|
|
1338
|
+
}
|
|
1339
|
+
return r;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// The parent `cmk config` action: handle the --show-origin flag here; the
|
|
1343
|
+
// get/set children carry their own actions (wired in the registry below).
|
|
1344
|
+
// Exported for the branch test (the no-subcommand path).
|
|
1345
|
+
export function runConfigCli(options /* , command */) {
|
|
1346
|
+
if (options?.showOrigin) {
|
|
1347
|
+
return runConfigShowOrigin(options.showOrigin, options);
|
|
1348
|
+
}
|
|
1349
|
+
const logError = options?.logError ?? console.error;
|
|
1350
|
+
logError(
|
|
1351
|
+
'cmk config: specify a subcommand — `get <key>`, `set <key> <value>`, or `--show-origin <key>`.',
|
|
1352
|
+
);
|
|
1353
|
+
process.exitCode = 2;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1192
1356
|
async function runRepairCli(options /* , command */) {
|
|
1193
1357
|
const projectRoot = resolvePath(process.cwd());
|
|
1194
1358
|
const userDir = join(homedir(), '.claude-memory-kit');
|
|
@@ -1304,6 +1468,59 @@ export async function runImportAnthropicMemory(options = {}) {
|
|
|
1304
1468
|
}
|
|
1305
1469
|
}
|
|
1306
1470
|
|
|
1471
|
+
// Task 142 (D-130): onboard from an existing rules file. Dep-injectable
|
|
1472
|
+
// (projectRoot / log / logError / importFn) on the runImportAnthropicMemory
|
|
1473
|
+
// pattern so the real CLI path is verifiable in a sandbox. `file` is the
|
|
1474
|
+
// optional positional (commander passes it first), defaulting to CLAUDE.md.
|
|
1475
|
+
export async function runImportClaudeMd(file, options = {}) {
|
|
1476
|
+
const projectRoot = options?.projectRoot ?? resolvePath(process.cwd());
|
|
1477
|
+
const log = options?.log ?? console.log;
|
|
1478
|
+
const logError = options?.logError ?? console.error;
|
|
1479
|
+
const dryRun = options?.dryRun === true;
|
|
1480
|
+
const acceptAll = options?.yes === true;
|
|
1481
|
+
const importFn = options?.importFn ?? importClaudeMd;
|
|
1482
|
+
try {
|
|
1483
|
+
const r = await importFn({ projectRoot, file, dryRun, acceptAll });
|
|
1484
|
+
if (r.action === 'error') {
|
|
1485
|
+
logError(`cmk import-claude-md: error — ${(r.errors ?? []).join('; ')}`);
|
|
1486
|
+
process.exitCode = 2;
|
|
1487
|
+
return r;
|
|
1488
|
+
}
|
|
1489
|
+
if (r.reason === 'no-source') {
|
|
1490
|
+
log(`cmk import-claude-md: no rules file found at ${r.sourcePath}`);
|
|
1491
|
+
return r;
|
|
1492
|
+
}
|
|
1493
|
+
if (r.reason) {
|
|
1494
|
+
// e.g. read-source-failed — completed-with-failure must not print the
|
|
1495
|
+
// success-shaped "applied 0" line (skill-review 2026-06-12 finding).
|
|
1496
|
+
logError(`cmk import-claude-md: ${r.reason} (${r.sourcePath})`);
|
|
1497
|
+
process.exitCode = 2;
|
|
1498
|
+
return r;
|
|
1499
|
+
}
|
|
1500
|
+
const listProposals = () => {
|
|
1501
|
+
for (const p of r.proposals) log(` + [${p.type}] L${p.line}: ${p.text}`);
|
|
1502
|
+
};
|
|
1503
|
+
if (r.mode === 'dry-run') {
|
|
1504
|
+
log(`cmk import-claude-md: dry-run — ${r.proposals.length} proposal(s), ${r.skipped} duplicate(s) skipped`);
|
|
1505
|
+
listProposals();
|
|
1506
|
+
return r;
|
|
1507
|
+
}
|
|
1508
|
+
if (r.mode === 'requires-confirmation') {
|
|
1509
|
+
log(`cmk import-claude-md: ${r.proposals.length} proposal(s) ready to apply.`);
|
|
1510
|
+
log(' Re-run with --yes to apply, or --dry-run to inspect.');
|
|
1511
|
+
listProposals();
|
|
1512
|
+
return r;
|
|
1513
|
+
}
|
|
1514
|
+
const rejectedNote = r.rejected > 0 ? `, ${r.rejected} rejected by Poison_Guard` : '';
|
|
1515
|
+
const errorNote = r.errors > 0 ? `, ${r.errors} error(s)` : '';
|
|
1516
|
+
log(`cmk import-claude-md: applied ${r.accepted} fact(s), skipped ${r.skipped} duplicate(s)${rejectedNote}${errorNote}`);
|
|
1517
|
+
return r;
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
logError(`cmk import-claude-md: unexpected error: ${err?.message ?? err}`);
|
|
1520
|
+
process.exitCode = 2;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1307
1524
|
async function runTranscriptsDispatch(childName, options) {
|
|
1308
1525
|
if (childName === 'extract') {
|
|
1309
1526
|
return runTranscriptsExtract(options);
|
|
@@ -1426,7 +1643,10 @@ async function runMcpDispatch(childName) {
|
|
|
1426
1643
|
}
|
|
1427
1644
|
return;
|
|
1428
1645
|
}
|
|
1429
|
-
|
|
1646
|
+
// A bare `cmk mcp` (no sub-verb) reaches here post-Task-129 (the parent
|
|
1647
|
+
// action is now wired) — commander passes an options object, not a string.
|
|
1648
|
+
const verb = typeof childName === 'string' ? childName : '(none)';
|
|
1649
|
+
console.error(`cmk mcp: ${NOTICE_PREFIX} (run \`cmk mcp serve\`; got sub-verb '${verb}')`);
|
|
1430
1650
|
process.exitCode = 2;
|
|
1431
1651
|
}
|
|
1432
1652
|
|
|
@@ -1437,7 +1657,10 @@ async function runQueueDispatch(childName) {
|
|
|
1437
1657
|
if (childName === 'review') {
|
|
1438
1658
|
return runQueueReview();
|
|
1439
1659
|
}
|
|
1440
|
-
|
|
1660
|
+
// A bare `cmk queue` reaches here post-Task-129 (parent action wired);
|
|
1661
|
+
// commander passes an options object, not a string sub-verb.
|
|
1662
|
+
const verb = typeof childName === 'string' ? childName : '(none)';
|
|
1663
|
+
console.log(`cmk queue: ${NOTICE_PREFIX} (run \`cmk queue review\` or \`cmk queue conflicts\`; got '${verb}')`);
|
|
1441
1664
|
process.exitCode = 2;
|
|
1442
1665
|
}
|
|
1443
1666
|
|
|
@@ -1798,27 +2021,32 @@ export const subcommands = [
|
|
|
1798
2021
|
},
|
|
1799
2022
|
{
|
|
1800
2023
|
name: 'config',
|
|
1801
|
-
description: 'settings
|
|
1802
|
-
milestone:
|
|
2024
|
+
description: 'read/write kit settings (context/settings.json) without hand-editing JSON',
|
|
2025
|
+
milestone: 129,
|
|
1803
2026
|
optionSpec: [
|
|
1804
|
-
{ flags: '--show-origin <key>', description: 'print
|
|
2027
|
+
{ flags: '--show-origin <key>', description: 'print every tier that defines a setting (winner + shadowed) — the "where did this come from?" debug surface' },
|
|
1805
2028
|
],
|
|
1806
2029
|
children: [
|
|
1807
2030
|
{
|
|
1808
2031
|
name: 'get',
|
|
1809
|
-
description: 'print the resolved value of a setting',
|
|
1810
|
-
argSpec: [{ flags: '<key>', description: 'setting key (dotted path)' }],
|
|
2032
|
+
description: 'print the resolved value of a setting (dotted key; local > project > user)',
|
|
2033
|
+
argSpec: [{ flags: '<key>', description: 'setting key (dotted path, e.g. search.default_mode)' }],
|
|
2034
|
+
action: (key, options) => runConfigGet(key, options),
|
|
1811
2035
|
},
|
|
1812
2036
|
{
|
|
1813
2037
|
name: 'set',
|
|
1814
|
-
description: 'set a setting in the
|
|
2038
|
+
description: 'set a setting in the project tier (or --local)',
|
|
1815
2039
|
argSpec: [
|
|
1816
2040
|
{ flags: '<key>', description: 'setting key (dotted path)' },
|
|
1817
|
-
{ flags: '<value>', description: 'new value' },
|
|
2041
|
+
{ flags: '<value>', description: 'new value (true/false/number coerced; else string)' },
|
|
2042
|
+
],
|
|
2043
|
+
optionSpec: [
|
|
2044
|
+
{ flags: '--local', description: 'write to the local tier (context.local/, gitignored) instead of project' },
|
|
1818
2045
|
],
|
|
2046
|
+
action: (key, value, options) => runConfigSet(key, value, { tier: options?.local ? 'local' : 'project' }),
|
|
1819
2047
|
},
|
|
1820
2048
|
],
|
|
1821
|
-
action:
|
|
2049
|
+
action: runConfigCli,
|
|
1822
2050
|
},
|
|
1823
2051
|
{
|
|
1824
2052
|
name: 'import-anthropic-memory',
|
|
@@ -1830,6 +2058,19 @@ export const subcommands = [
|
|
|
1830
2058
|
],
|
|
1831
2059
|
action: runImportAnthropicMemory,
|
|
1832
2060
|
},
|
|
2061
|
+
{
|
|
2062
|
+
name: 'import-claude-md',
|
|
2063
|
+
description: 'onboard from an existing rules file (CLAUDE.md / .cursorrules / AGENTS.md) — parse it into typed facts through the safe write path',
|
|
2064
|
+
milestone: 142,
|
|
2065
|
+
argSpec: [
|
|
2066
|
+
{ flags: '[file]', description: 'rules file to import, relative to the project root (default: CLAUDE.md)' },
|
|
2067
|
+
],
|
|
2068
|
+
optionSpec: [
|
|
2069
|
+
{ flags: '--dry-run', description: 'preview the typed proposals without modifying files' },
|
|
2070
|
+
{ flags: '--yes', description: 'apply every proposal without prompting (apply requires explicit --yes)' },
|
|
2071
|
+
],
|
|
2072
|
+
action: runImportClaudeMd,
|
|
2073
|
+
},
|
|
1833
2074
|
{
|
|
1834
2075
|
name: 'transcripts',
|
|
1835
2076
|
description: "extract clean markdown transcripts from Claude Code session jsonls under ~/.claude/projects/",
|
package/src/transcript-index.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// chunkTranscript(text) → [{heading, body, sourceLine, chunkIdx}] (pure)
|
|
16
16
|
// syncTranscriptChunks({db, projectRoot, now?}) → {files, chunks}
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { hashContent } from './content-hash.mjs';
|
|
19
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
|
|
@@ -57,8 +57,11 @@ export function chunkTranscript(text) {
|
|
|
57
57
|
return chunks;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Transcript-chunk fingerprint for the `files`-table diff key (column name
|
|
61
|
+
// `sha1` kept for checkpoint back-compat; algorithm is SHA-256 via hashContent,
|
|
62
|
+
// D-149). Self-heals on the first post-upgrade boot like the observation index.
|
|
60
63
|
function sha1(text) {
|
|
61
|
-
return
|
|
64
|
+
return hashContent(text);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// Task 126 (D-119) — the raw-tier scope covers BOTH halves of the session
|
package/src/write-fact.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { reindex } from './reindex.mjs';
|
|
|
21
21
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
22
22
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
23
23
|
import { sanitizeHomePaths } from './sanitize.mjs';
|
|
24
|
+
import { sanitizePrivacyTags } from './privacy.mjs';
|
|
24
25
|
import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
|
|
25
26
|
|
|
26
27
|
const VALID_TYPES = new Set(['user', 'feedback', 'project', 'reference']);
|
|
@@ -157,6 +158,14 @@ export function writeFact(opts = {}) {
|
|
|
157
158
|
// — that's its purpose. The id hashes the SANITIZED body, so dedup keys on
|
|
158
159
|
// what actually lands on disk.
|
|
159
160
|
let { body, title } = opts;
|
|
161
|
+
// Privacy: strip <private>…</private> FIRST, on EVERY tier (cut-gate
|
|
162
|
+
// v0.3.1 finding — the tag was honored only by the UserPromptSubmit hook,
|
|
163
|
+
// so a fact written via cmk remember/mk_remember/import kept the secret).
|
|
164
|
+
// Runs before home-path sanitization, Poison_Guard, and id-generation, so
|
|
165
|
+
// the redacted body is what gets screened, hashed (dedup keys on what
|
|
166
|
+
// lands), and written.
|
|
167
|
+
body = sanitizePrivacyTags(body);
|
|
168
|
+
title = sanitizePrivacyTags(title);
|
|
160
169
|
if (opts.tier === 'P' || opts.tier === 'U') {
|
|
161
170
|
body = sanitizeHomePaths(body);
|
|
162
171
|
title = sanitizeHomePaths(title);
|
|
@@ -252,10 +261,32 @@ export function writeFact(opts = {}) {
|
|
|
252
261
|
// 2026-06-03 — "users should get it working from the start"). Best-effort: the
|
|
253
262
|
// fact is already durably on disk, so an index-rebuild hiccup must not turn a
|
|
254
263
|
// successful capture into an error — the next reindex/search self-heals.
|
|
264
|
+
//
|
|
265
|
+
// D-152: the failure is OBSERVABLE, not silently swallowed. A detached
|
|
266
|
+
// auto-extract child whose reindex was killed mid-rebuild (hook ceiling) used
|
|
267
|
+
// to leave INDEX.md lagging with ZERO trace — so a stale committed INDEX was
|
|
268
|
+
// undiagnosable (the user caught a 5-fact lag in the cut-gate). On throw we
|
|
269
|
+
// now record an INDEX_REBUILD_FAILED audit entry; HC-4 still detects the drift
|
|
270
|
+
// and `cmk reindex` corrects it. The `_reindexFn` seam is test-only.
|
|
271
|
+
const doReindex = opts._reindexFn ?? reindex;
|
|
255
272
|
try {
|
|
256
|
-
|
|
257
|
-
} catch {
|
|
258
|
-
// index rebuild is best-effort; capture already succeeded
|
|
273
|
+
doReindex({ tier: opts.tier, projectRoot: opts.projectRoot, userDir: opts.userDir, warn: () => {} });
|
|
274
|
+
} catch (reindexErr) {
|
|
275
|
+
// index rebuild is best-effort; capture already succeeded — but leave a
|
|
276
|
+
// trace so a lagging committed INDEX is diagnosable, never silent.
|
|
277
|
+
try {
|
|
278
|
+
appendAuditEntry(tierRoot, {
|
|
279
|
+
ts: createdAt,
|
|
280
|
+
action: 'index-rebuild-failed',
|
|
281
|
+
tier: opts.tier,
|
|
282
|
+
id,
|
|
283
|
+
reasonCode: REASON_CODES.INDEX_REBUILD_FAILED,
|
|
284
|
+
paths: { after: path },
|
|
285
|
+
extra: { error: String(reindexErr?.message ?? reindexErr) },
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// even the audit append is best-effort; the fact is already on disk
|
|
289
|
+
}
|
|
259
290
|
}
|
|
260
291
|
|
|
261
292
|
// Default create-audit (Task 123.A / D-103). writeFact is the single boundary
|