@lh8ppl/claude-memory-kit 0.2.4 → 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.
Files changed (43) hide show
  1. package/README.md +16 -10
  2. package/bin/cmk-capture-prompt.mjs +21 -1
  3. package/package.json +2 -1
  4. package/src/audit-log.mjs +1 -0
  5. package/src/auto-drain.mjs +17 -1
  6. package/src/auto-extract.mjs +72 -16
  7. package/src/auto-persona.mjs +86 -1
  8. package/src/capture-prompt.mjs +34 -1
  9. package/src/capture-turn.mjs +64 -6
  10. package/src/config-core.mjs +161 -0
  11. package/src/conflict-queue.mjs +20 -3
  12. package/src/content-hash.mjs +30 -0
  13. package/src/doctor.mjs +62 -3
  14. package/src/forget.mjs +13 -0
  15. package/src/frontmatter.mjs +4 -1
  16. package/src/import-anthropic-memory.mjs +25 -1
  17. package/src/import-claude-md.mjs +333 -0
  18. package/src/index-db.mjs +39 -0
  19. package/src/index-rebuild.mjs +48 -4
  20. package/src/index.mjs +10 -0
  21. package/src/inject-context.mjs +179 -7
  22. package/src/install.mjs +180 -1
  23. package/src/mcp-server.mjs +63 -8
  24. package/src/memory-health.mjs +229 -0
  25. package/src/memory-write.mjs +32 -10
  26. package/src/merge-facts.mjs +12 -0
  27. package/src/native-binding.mjs +142 -0
  28. package/src/poison-guard.mjs +55 -0
  29. package/src/provenance.mjs +4 -0
  30. package/src/remember-core.mjs +53 -8
  31. package/src/repair.mjs +20 -3
  32. package/src/result-shapes.mjs +1 -1
  33. package/src/scratchpad.mjs +5 -3
  34. package/src/search.mjs +96 -9
  35. package/src/semantic-backend.mjs +599 -0
  36. package/src/settings-hooks.mjs +4 -1
  37. package/src/subcommands.mjs +359 -42
  38. package/src/transcript-index.mjs +165 -0
  39. package/src/turn-tools.mjs +179 -0
  40. package/src/write-fact.mjs +34 -3
  41. package/template/.claude/skills/memory-search/SKILL.md +86 -0
  42. package/template/.gitattributes.fragment +16 -0
  43. package/template/CLAUDE.md.template +3 -1
@@ -26,14 +26,19 @@ import {
26
26
  readdirSync,
27
27
  appendFileSync,
28
28
  statSync,
29
+ openSync,
30
+ readSync,
31
+ closeSync,
29
32
  } from 'node:fs';
30
33
  import { spawn } from 'node:child_process';
31
34
  import { join } from 'node:path';
32
35
  import { homedir } from 'node:os';
33
- import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
36
+ import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
34
37
  import { nowIso } from './audit-log.mjs';
35
38
  import { detectStaleness } from './lazy-compress.mjs';
36
39
  import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
40
+ import { listConflictQueue } from './conflict-queue.mjs';
41
+ import { listReviewQueue } from './review-queue.mjs';
37
42
 
38
43
  // Importance ranking for value-ordered inject eviction (Task 93 / design §19.3).
39
44
  // When a tier exceeds its budget we drop the LOWEST-value sections first, not the
@@ -61,6 +66,33 @@ function trustLabel(rank) {
61
66
  const DEFAULT_CAP_BYTES = 13_000;
62
67
  const HOOK_EVENT_NAME = 'SessionStart';
63
68
 
69
+ // Task 75.0 (D-64 / memory-os Layer-07 "Ground Truth", D-73 near-verbatim):
70
+ // injecting memory is insufficient — the agent must be TOLD the injected
71
+ // context is authoritative, or it re-derives from code what the snapshot
72
+ // already answers (the D-40 cold-open failure). This preamble leads every
73
+ // non-empty snapshot. It is code-generated (not template-scaffolded) on
74
+ // purpose: always present, never consolidated/evicted/graduated, and
75
+ // existing installs pick it up on upgrade with no re-scaffold (avoids the
76
+ // Task-73 stale-template class).
77
+ //
78
+ // §7.1 composition: the preamble + its 2 joining newlines must fit the
79
+ // 725-byte slack between Σ TIER_BUDGETS (12,275) and DEFAULT_CAP_BYTES
80
+ // (13,000) — worst case 12,275 + len + 2 ≤ 13,000, i.e. len ≤ 723. The
81
+ // boundary test pins len ≤ 700. injectContext also subtracts the reserve
82
+ // from the cap handed to enforceCap, so custom capBytes stay honored.
83
+ export const AUTHORITATIVE_MEMORY_PREAMBLE = [
84
+ '# Injected memory — AUTHORITATIVE (claude-memory-kit)',
85
+ '',
86
+ 'Ground-truth ranking: (1) terminal/tool output → live system state;',
87
+ '(2) THIS snapshot + `cmk search` → documented knowledge & prior decisions;',
88
+ '(3) official docs → version-specifics; (4) training knowledge → verify against 1-3.',
89
+ '',
90
+ 'When injected memory contradicts your assumptions, injected memory wins.',
91
+ 'Lead with memory — never re-derive from code what it already answers, and',
92
+ 'never treat a question as novel when the answer is already in your prompt.',
93
+ 'This snapshot is a bounded hot index; `cmk search "<topic>"` reaches the facts not shown here.',
94
+ ].join('\n');
95
+
64
96
  // Match any line containing a `(P-XXXXXXXX)`-shaped citation id. Looser
65
97
  // than ID_PATTERN on purpose — alphabet-validation is the writer's job;
66
98
  // here we just want to recognize "any line that LOOKS like it carries a
@@ -520,7 +552,12 @@ function truncateTierToBudget(blockText, budget, valueById = new Map()) {
520
552
  // lowest-priority tier wholesale, logged as a dropped_tiers event.
521
553
  // This shouldn't fire under the documented budget table (1500+4500+
522
554
  // 4000 = 10000 ≤ 10240 default cap), but the safety net is cheap.
523
- function enforceCap(orderedBlocks, capBytes, ts) {
555
+ // `reportCapBytes` (Task 75.0): the CALLER-facing cap for Door-4 events.
556
+ // injectContext hands enforceCap a cap reduced by the preamble reserve;
557
+ // truncation.log must still report the capBytes the user configured, not
558
+ // the internal effective value, or the log reads as nonsense (411 when
559
+ // the user set 1024).
560
+ function enforceCap(orderedBlocks, capBytes, ts, reportCapBytes = capBytes) {
524
561
  const tierEvents = [];
525
562
  // Step 1: per-tier budget enforcement (section-granular).
526
563
  for (const block of orderedBlocks) {
@@ -559,7 +596,7 @@ function enforceCap(orderedBlocks, capBytes, ts) {
559
596
  bytes -= Buffer.byteLength(dropped.text, 'utf8');
560
597
  let event = dropEvents[dropEvents.length - 1];
561
598
  if (!event) {
562
- event = { ts, capBytes, dropped_tiers: [] };
599
+ event = { ts, capBytes: reportCapBytes, dropped_tiers: [] };
563
600
  dropEvents.push(event);
564
601
  }
565
602
  event.dropped_tiers.push(dropped.tier);
@@ -707,15 +744,26 @@ export function injectContext({
707
744
  }
708
745
 
709
746
  // 3. Cap enforcement: drop whole tier blocks from the tail until within
710
- // capBytes. Each drop emits one truncation event.
747
+ // capBytes. Each drop emits one truncation event. The authoritative-memory
748
+ // preamble (Task 75.0) is reserved out of the cap up front so the final
749
+ // snapshot (preamble + blocks) still honors capBytes exactly.
750
+ const preambleReserve =
751
+ rawBlocks.length > 0
752
+ ? Buffer.byteLength(AUTHORITATIVE_MEMORY_PREAMBLE, 'utf8') + 2
753
+ : 0;
711
754
  const { blocks: keptBlocks, truncationEvents } = enforceCap(
712
755
  rawBlocks,
713
- cap,
756
+ Math.max(0, cap - preambleReserve),
714
757
  ts,
758
+ cap,
715
759
  );
716
760
 
717
- // 4. Concatenate.
718
- const snapshot = keptBlocks.map((b) => b.text).join('\n');
761
+ // 4. Concatenate. The preamble leads every non-empty snapshot; an empty
762
+ // snapshot stays empty (don't claim authoritative memory with nothing
763
+ // behind it).
764
+ const body = keptBlocks.map((b) => b.text).join('\n');
765
+ const snapshot =
766
+ body === '' ? '' : `${AUTHORITATIVE_MEMORY_PREAMBLE}\n\n${body}`;
719
767
 
720
768
  // 5. Persist side-effect logs under <projectRoot>/context/.locks/. We
721
769
  // only write the project-tier .locks file (which is the well-known
@@ -757,7 +805,14 @@ export function injectContext({
757
805
  // 7. Emit the Anthropic SessionStart hook output shape (design §5.1 +
758
806
  // Anthropic hook protocol). When the snapshot is empty, we still emit
759
807
  // the shape so downstream tooling can rely on the field's presence.
808
+ //
809
+ // Task 145 (D-130): `systemMessage` is the USER-DISPLAY channel (the
810
+ // D-116 primary-source check: additionalContext is model-facing,
811
+ // systemMessage is shown to the user) — one status line per session
812
+ // start, zero model-token cost. The trust loop every silent system
813
+ // lacks: when the kit works, the user finally SEES it working.
760
814
  const hookOutput = {
815
+ systemMessage: buildStatusLine({ snapshot, projectRoot, now: ts }),
761
816
  hookSpecificOutput: {
762
817
  hookEventName: HOOK_EVENT_NAME,
763
818
  additionalContext: snapshot,
@@ -773,3 +828,120 @@ export function injectContext({
773
828
  bytes: Buffer.byteLength(snapshot, 'utf8'),
774
829
  };
775
830
  }
831
+
832
+ // --- Task 145: the session-start status line (user-display) -------------
833
+
834
+ // Tail-read budget for audit.log: recency lives at the end; reading the
835
+ // whole file would grow with project age inside a 500ms-budget hook.
836
+ const STATUS_AUDIT_TAIL_BYTES = 64 * 1024;
837
+ const DAY_MS = 24 * 60 * 60 * 1000;
838
+ // Derived from the shared ID_PATTERN (tier-paths.mjs) — strip its ^/$
839
+ // anchors and wrap in the `(id)` bullet form. One alphabet, one source.
840
+ const SNAPSHOT_ID_RE = new RegExp(`\\((${ID_PATTERN.source.slice(1, -1)})\\)`, 'g');
841
+
842
+ /**
843
+ * One user-facing line summarizing what the kit just did for this session.
844
+ * Best-effort everywhere: a status line must NEVER turn a working hook into
845
+ * a crash — every data source degrades to its zero independently.
846
+ *
847
+ * @param {object} opts
848
+ * @param {string} opts.snapshot - the composed injection snapshot.
849
+ * @param {string} opts.projectRoot
850
+ * @param {string} [opts.now]
851
+ * @param {Function} [opts.listConflictsImpl] - test seam (default: the real queue lister).
852
+ * @param {Function} [opts.listReviewImpl] - test seam.
853
+ * @returns {string} the status line (always a string, never throws).
854
+ */
855
+ export function buildStatusLine({
856
+ snapshot,
857
+ projectRoot,
858
+ now,
859
+ listConflictsImpl,
860
+ listReviewImpl,
861
+ } = {}) {
862
+ const prefix = 'claude-memory-kit:';
863
+ try {
864
+ // 1. Unique injected fact ids — what the model can actually see.
865
+ const ids = new Set();
866
+ for (const m of String(snapshot ?? '').matchAll(SNAPSHOT_ID_RE)) ids.add(m[1]);
867
+
868
+ if (ids.size === 0) {
869
+ return `${prefix} memory is empty — capture starts this session`;
870
+ }
871
+ const parts = [`${ids.size} fact(s) in context`];
872
+
873
+ // 2. Captures in the last 24h, from the audit-log tail. A capture is a
874
+ // `created` entry or an APPLIED import — `action: 'import'` alone also
875
+ // covers skipped duplicates (reasonCode import-skipped-duplicate), and
876
+ // counting those would let a re-run import inflate the line by its
877
+ // whole dup count (skill-review finding, 2026-06-12).
878
+ const nowMs = Date.parse(now ?? nowIso());
879
+ let recent = 0;
880
+ try {
881
+ const auditPath = join(projectRoot, 'context', '.locks', 'audit.log');
882
+ if (existsSync(auditPath)) {
883
+ // Positioned read of the LAST 64KB only — recency lives at the end,
884
+ // and this runs inside the 500ms-budget SessionStart hook; reading a
885
+ // months-old multi-MB log in full would pay for history we discard.
886
+ const size = statSync(auditPath).size;
887
+ const start = Math.max(0, size - STATUS_AUDIT_TAIL_BYTES);
888
+ const buf = Buffer.alloc(size - start);
889
+ const fd = openSync(auditPath, 'r');
890
+ try {
891
+ readSync(fd, buf, 0, buf.length, start);
892
+ } finally {
893
+ closeSync(fd);
894
+ }
895
+ // Drop the (possibly torn) first line when we started mid-file.
896
+ const tail = start > 0 ? buf.toString('utf8').replace(/^[^\n]*\n/, '') : buf.toString('utf8');
897
+ for (const line of tail.split(/\r?\n/)) {
898
+ if (!line.trim()) continue;
899
+ try {
900
+ const e = JSON.parse(line);
901
+ const isCapture =
902
+ e.action === 'created' ||
903
+ (e.action === 'import' && e.reasonCode === 'import-applied');
904
+ if (
905
+ isCapture &&
906
+ nowMs - Date.parse(e.ts) <= DAY_MS &&
907
+ nowMs - Date.parse(e.ts) >= 0
908
+ ) {
909
+ recent += 1;
910
+ }
911
+ } catch {
912
+ // torn NDJSON line — skip
913
+ }
914
+ }
915
+ }
916
+ } catch {
917
+ // audit log unreadable — the count degrades to absent
918
+ }
919
+ if (recent > 0) parts.push(`${recent} captured in the last 24h`);
920
+
921
+ // 3. Pending curation — only mentioned when non-zero (a quiet queue
922
+ // earns a quiet line).
923
+ let conflicts = 0;
924
+ let review = 0;
925
+ try {
926
+ conflicts = (listConflictsImpl ?? listConflictQueue)({ tier: 'P', projectRoot }).length;
927
+ } catch {
928
+ // queue unreadable — degrade to zero
929
+ }
930
+ try {
931
+ review = (listReviewImpl ?? listReviewQueue)({ tier: 'P', projectRoot }).length;
932
+ } catch {
933
+ // queue unreadable — degrade to zero
934
+ }
935
+ if (conflicts > 0 || review > 0) {
936
+ const q = [];
937
+ if (conflicts > 0) q.push(`${conflicts} conflict(s)`);
938
+ if (review > 0) q.push(`${review} review item(s)`);
939
+ parts.push(`${q.join(' + ')} pending — cmk queue`);
940
+ }
941
+
942
+ return `${prefix} ${parts.join(', ')}`;
943
+ } catch {
944
+ // The line is decoration; the snapshot is the cargo. Never crash.
945
+ return `${prefix} memory loaded`;
946
+ }
947
+ }
package/src/install.mjs CHANGED
@@ -39,9 +39,11 @@ import {
39
39
  writeFileSync,
40
40
  } from 'node:fs';
41
41
  import { homedir } from 'node:os';
42
+ import { spawnSync } from 'node:child_process';
42
43
  import { basename, dirname, join, relative, resolve } from 'node:path';
43
44
  import { fileURLToPath } from 'node:url';
44
45
  import { injectClaudeMdBlock } from './claude-md.mjs';
46
+ import { checkKitBinding, npmSupportsAllowScripts } from './native-binding.mjs';
45
47
  import { writeKitHooks, writeKitMcpServer } from './settings-hooks.mjs';
46
48
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
47
49
 
@@ -57,6 +59,13 @@ const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
57
59
  // it must not show a stale hardcode (was `v0.1.0` in every install). Built per
58
60
  // install from the kit version; see gitignoreStartMarker().
59
61
  const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
62
+ // D-126 CRLF-prevention: the .gitattributes managed block uses the SAME
63
+ // marker discipline as .gitignore (version-stamped start, in-place refresh).
64
+ const GITATTRIBUTES_END = '# claude-memory-kit:gitattributes:end';
65
+
66
+ function gitattributesStartMarker(version) {
67
+ return `# claude-memory-kit:gitattributes:start v${version}`;
68
+ }
60
69
 
61
70
  function gitignoreStartMarker(version) {
62
71
  return `# claude-memory-kit:gitignore:start v${version}`;
@@ -232,6 +241,52 @@ function buildGitignoreBlock(templateDir, version = getKitVersion()) {
232
241
  return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
233
242
  }
234
243
 
244
+ /**
245
+ * Build the canonical .gitattributes managed block from
246
+ * template/.gitattributes.fragment (D-126 CRLF prevention — force LF on the
247
+ * committed memory tiers so default Windows git doesn't mangle the bytes at
248
+ * clone). Same marker discipline as the .gitignore block.
249
+ */
250
+ function buildGitattributesBlock(templateDir, version = getKitVersion()) {
251
+ const fragmentPath = join(templateDir, '.gitattributes.fragment');
252
+ const fragment = existsSync(fragmentPath)
253
+ ? readFileSync(fragmentPath, 'utf8').trim()
254
+ : 'context/**/*.md text eol=lf\ncontext/**/*.json text eol=lf';
255
+ return `${gitattributesStartMarker(version)}\n${fragment}\n${GITATTRIBUTES_END}\n`;
256
+ }
257
+
258
+ /**
259
+ * Inject (or refresh) the managed .gitattributes block. Same algorithm as
260
+ * injectGitignore (create / append-if-no-markers / replace-in-place),
261
+ * byte-preserving everything outside the markers.
262
+ *
263
+ * Returns: { action: 'created' | 'replaced' | 'unchanged', path: string }
264
+ */
265
+ function injectGitattributes(projectRoot, block) {
266
+ const gaPath = join(projectRoot, '.gitattributes');
267
+ const startRe = /# claude-memory-kit:gitattributes:start[^\n]*\n/;
268
+ const endRe = /# claude-memory-kit:gitattributes:end\n?/;
269
+
270
+ if (!existsSync(gaPath)) {
271
+ writeFileSync(gaPath, block, 'utf8');
272
+ return { action: 'created', path: gaPath };
273
+ }
274
+ const existing = readFileSync(gaPath, 'utf8');
275
+ const startMatch = existing.match(startRe);
276
+ const endMatch = existing.match(endRe);
277
+ if (!startMatch || !endMatch || startMatch.index > endMatch.index) {
278
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
279
+ writeFileSync(gaPath, existing + sep + block, 'utf8');
280
+ return { action: 'created', path: gaPath };
281
+ }
282
+ const before = existing.slice(0, startMatch.index);
283
+ const after = existing.slice(endMatch.index + endMatch[0].length);
284
+ const next = before + block + after;
285
+ if (next === existing) return { action: 'unchanged', path: gaPath };
286
+ writeFileSync(gaPath, next, 'utf8');
287
+ return { action: 'replaced', path: gaPath };
288
+ }
289
+
235
290
  /**
236
291
  * Inject (or refresh) the managed .gitignore block in `<projectRoot>/.gitignore`.
237
292
  *
@@ -327,6 +382,10 @@ export async function install(options = {}) {
327
382
  }
328
383
 
329
384
  const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
385
+ // D-126 CRLF prevention: pin LF on the committed memory tiers so default
386
+ // Windows git can't mangle the bytes at clone (the read-side self-heal
387
+ // shipped in v0.3.0; this prevents the mangling in the first place).
388
+ const gitattributes = injectGitattributes(projectRoot, buildGitattributesBlock(templateDir, version));
330
389
 
331
390
  // CLAUDE.md loader block — Task 4. Read the block content from the kit's
332
391
  // template/ and inject (or refresh) it inside marker delimiters. Never
@@ -411,7 +470,127 @@ export async function install(options = {}) {
411
470
  }
412
471
  }
413
472
 
414
- return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, mcpServer, errors };
473
+ // Task 46 semantic-recall opt-in/out. `--with-semantic`: install the
474
+ // optional embedder (~260 MB once, fully local), flip the project's
475
+ // default search mode to hybrid, and pre-warm the model so the one-time
476
+ // download happens NOW, not as a surprise on the first search.
477
+ // `--no-semantic`: pin keyword explicitly. Neither flag → settings
478
+ // untouched (keyword by absence). The npm spawn is injectable
479
+ // (options.spawnNpm) so tests assert the argv without touching the host.
480
+ // Both flags together → withSemantic wins (the affirmative opt-in beats
481
+ // the pin-off; checked first below).
482
+ let semantic = { action: 'skipped' };
483
+ if (options.withSemantic) {
484
+ semantic = await enableSemantic({ projectRoot, spawnNpm: options.spawnNpm, warm: options.warmEmbedder });
485
+ if (semantic.action === 'error') errors.push({ path: 'semantic', error: semantic.error });
486
+ } else if (options.noSemantic) {
487
+ const r = mergeProjectSettings(projectRoot, { search: { default_mode: 'keyword' } });
488
+ semantic = r.ok
489
+ ? { action: 'disabled', path: r.path }
490
+ : { action: 'error', error: r.error };
491
+ if (!r.ok) errors.push({ path: r.path, error: r.error });
492
+ }
493
+
494
+ // Task 141a (D-129): probe the kit's native binding so the CLI can ask the
495
+ // user to fix it INLINE (npm 12 blocks better-sqlite3's binding build on a
496
+ // fresh install). Reported, never an installer error — scaffold + hooks
497
+ // are fully functional without it; only search/reindex need the binding.
498
+ const bindingProbe = options.bindingProbe ?? checkKitBinding;
499
+ const nativeBinding = bindingProbe();
500
+
501
+ return { projectRoot, userTier, created, skipped, gitignore, gitattributes, claudeMd, hooks, mcpServer, semantic, nativeBinding, errors };
502
+ }
503
+
504
+ /**
505
+ * Read-merge-write <projectRoot>/context/settings.json, preserving every
506
+ * key the user already has (over-mutation-safe; deep-merges one level).
507
+ */
508
+ export function mergeProjectSettings(projectRoot, patch) {
509
+ const path = join(projectRoot, 'context', 'settings.json');
510
+ try {
511
+ let current = {};
512
+ if (existsSync(path)) {
513
+ current = JSON.parse(readFileSync(path, 'utf8'));
514
+ }
515
+ const next = { ...current };
516
+ for (const [key, value] of Object.entries(patch)) {
517
+ next[key] =
518
+ value && typeof value === 'object' && !Array.isArray(value)
519
+ ? { ...(current[key] ?? {}), ...value }
520
+ : value;
521
+ }
522
+ mkdirSync(dirname(path), { recursive: true });
523
+ writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
524
+ return { ok: true, path };
525
+ } catch (err) {
526
+ return { ok: false, path, error: err?.message ?? String(err) };
527
+ }
528
+ }
529
+
530
+ /**
531
+ * The production npm-spawn closure, as an injectable-seam factory
532
+ * (Task 125.4) so its argv/shell/timeout contract is testable without
533
+ * running a real `npm install -g` (which stays a machine-level step
534
+ * tests must never take).
535
+ */
536
+ export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
537
+ return () => {
538
+ // Task 141a (D-129): on npm ≥ 11.16 the `allow-scripts` config exists
539
+ // and npm 12 BLOCKS onnxruntime-node's install script without it — the
540
+ // kit runs this install itself, so it carries the allow flag itself
541
+ // (no user friction). Older npm: plain command, no unknown-config noise.
542
+ const { supported } = npmSupportsAllowScripts({ spawnSyncImpl });
543
+ const cmd = supported
544
+ ? 'npm install -g @huggingface/transformers --allow-scripts=onnxruntime-node'
545
+ : 'npm install -g @huggingface/transformers';
546
+ // One constant command string under shell:true (no user input — and
547
+ // an args array + shell:true trips Node's DEP0190). npm is npm.cmd
548
+ // on Windows; the shell resolves it cross-platform.
549
+ const r = spawnSyncImpl(cmd, {
550
+ encoding: 'utf8',
551
+ stdio: 'inherit',
552
+ shell: true,
553
+ // spawn-discipline (design §8.5): a hung registry shouldn't hang
554
+ // install forever; 10 min covers the ~46 MB package on slow links.
555
+ timeout: 600_000,
556
+ });
557
+ return { status: r.status, error: r.error?.message };
558
+ };
559
+ }
560
+
561
+ async function enableSemantic({ projectRoot, spawnNpm, warm }) {
562
+ // 1. Install the optional embedder globally (it resolves as a sibling of
563
+ // the globally-installed kit). Injectable for tests.
564
+ const runNpm = spawnNpm ?? buildDefaultNpmRunner();
565
+ const npm = runNpm();
566
+ if (npm.status !== 0) {
567
+ return {
568
+ action: 'error',
569
+ error: `npm install -g @huggingface/transformers failed (${npm.error ?? `exit ${npm.status}`}) — semantic recall NOT enabled; keyword search is unaffected`,
570
+ };
571
+ }
572
+ // 2. Flip the project default to hybrid ONLY after the dependency landed
573
+ // (no half-state: a hybrid default without an embedder would degrade
574
+ // every search to a fallback warning).
575
+ const settings = mergeProjectSettings(projectRoot, { search: { default_mode: 'hybrid' } });
576
+ if (!settings.ok) {
577
+ return { action: 'error', error: settings.error };
578
+ }
579
+ // 3. Pre-warm (best-effort): the one-time model download happens during
580
+ // install, not on the first search. Injectable for tests.
581
+ let warmed = { ok: false, reason: 'skipped' };
582
+ try {
583
+ const warmFn =
584
+ warm ??
585
+ (async () => {
586
+ const { warmEmbedder } = await import('./semantic-backend.mjs');
587
+ return warmEmbedder();
588
+ });
589
+ warmed = await warmFn();
590
+ } catch (err) {
591
+ warmed = { ok: false, reason: err?.message ?? String(err) };
592
+ }
593
+ return { action: 'enabled', path: settings.path, defaultMode: 'hybrid', warmed };
415
594
  }
416
595
 
417
596
  /**
@@ -40,7 +40,7 @@ import { openIndexDb } from './index-db.mjs';
40
40
  import { reindexBoot } from './index-rebuild.mjs';
41
41
  import { search, SEARCH_MODES } from './search.mjs';
42
42
  import { memoryWrite } from './memory-write.mjs';
43
- import { rememberRich, nonProjectTierNote } from './remember-core.mjs';
43
+ import { rememberRich, nonProjectTierNote, prepareNearDupGuard } from './remember-core.mjs';
44
44
  import { forget } from './forget.mjs';
45
45
  import { overrideTrust } from './trust.mjs';
46
46
  import { lessonsPromote } from './lessons-promote.mjs';
@@ -106,16 +106,60 @@ export function validatePath(p, { projectRoot, userDir }) {
106
106
 
107
107
  // --- Tool handlers ----------------------------------------------------
108
108
 
109
- function makeMkSearch({ db, semanticBackend }) {
110
- return async ({ query, mode, tier, since, limit, min_trust }) => {
109
+ function makeMkSearch({ db, semanticBackend, projectRoot }) {
110
+ return async ({ query, mode, scope, tier, since, limit, min_trust }) => {
111
+ // Task 46: explicit mode wins; otherwise the project's configured
112
+ // default (search.default_mode — set by `cmk install --with-semantic`).
113
+ const { prepareSemanticBackend, resolveDefaultSearchMode } = await import(
114
+ './semantic-backend.mjs'
115
+ );
116
+ let wantMode =
117
+ mode ??
118
+ (projectRoot ? resolveDefaultSearchMode({ projectRoot }) : SEARCH_MODES.KEYWORD);
119
+ // Task 65: when the caller asks for semantic/hybrid and no test seam is
120
+ // injected, prepare the REAL embedded backend (lazy-optional — an absent
121
+ // embedder degrades to the actionable error below; keyword unaffected).
122
+ let backend = semanticBackend;
123
+ let degradedNote = null;
124
+ if (
125
+ backend === undefined &&
126
+ (wantMode === SEARCH_MODES.SEMANTIC || wantMode === SEARCH_MODES.HYBRID)
127
+ ) {
128
+ const prep = await prepareSemanticBackend({ db, query, scope: scope ?? 'facts' });
129
+ if (!prep.ok && mode) {
130
+ // Explicitly requested — surface the actionable error.
131
+ return {
132
+ content: [
133
+ {
134
+ type: 'text',
135
+ text: `error: semantic backend unavailable (${prep.reason}). ${prep.hint ?? 'Use mode "keyword".'}`,
136
+ },
137
+ ],
138
+ isError: true,
139
+ };
140
+ }
141
+ if (!prep.ok) {
142
+ // Configured default can't run — degrade gracefully to keyword,
143
+ // but NOT silently (Task 125.1, the user's call reversing the
144
+ // Task-46 review skip): the note below tells the model what it
145
+ // got, so it can relay the fix to the user.
146
+ wantMode = SEARCH_MODES.KEYWORD;
147
+ degradedNote =
148
+ `note: this project's configured default search is semantic (hybrid), but the embedder is unavailable (${prep.reason}) — these are keyword-only results. ` +
149
+ 'Suggest the user run `cmk install --with-semantic` to restore semantic recall.';
150
+ } else {
151
+ backend = prep.backend;
152
+ }
153
+ }
111
154
  const r = search({
112
155
  db, query,
113
- mode: mode ?? SEARCH_MODES.KEYWORD,
156
+ mode: wantMode,
157
+ scope,
114
158
  tier,
115
159
  since,
116
160
  limit,
117
161
  minTrust: min_trust,
118
- semanticBackend,
162
+ semanticBackend: backend,
119
163
  });
120
164
  if (r.action === 'error') {
121
165
  return {
@@ -124,7 +168,12 @@ function makeMkSearch({ db, semanticBackend }) {
124
168
  };
125
169
  }
126
170
  return {
127
- content: [{ type: 'text', text: JSON.stringify(r.results, null, 2) }],
171
+ content: [
172
+ { type: 'text', text: JSON.stringify(r.results, null, 2) },
173
+ // Results stay content[0] (shape-compatible); the degradation note,
174
+ // when present, rides as a second block.
175
+ ...(degradedNote ? [{ type: 'text', text: degradedNote }] : []),
176
+ ],
128
177
  };
129
178
  };
130
179
  }
@@ -232,6 +281,10 @@ function makeMkRemember({ projectRoot, userDir }) {
232
281
  ],
233
282
  };
234
283
  }
284
+ // Task 143 (D-130): the semantic near-dup guard (one embed of the
285
+ // incoming text when the project is semantic-configured + the embedder
286
+ // is available; {} = literal pipeline, never blocks capture).
287
+ const nearDup = await prepareNearDupGuard({ projectRoot, text });
235
288
  const r = memoryWrite({
236
289
  action: 'add',
237
290
  text,
@@ -242,6 +295,7 @@ function makeMkRemember({ projectRoot, userDir }) {
242
295
  sessionId: 'mcp-server',
243
296
  projectRoot,
244
297
  userDir,
298
+ ...nearDup,
245
299
  });
246
300
  if (r.action === 'error') {
247
301
  return {
@@ -505,17 +559,18 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
505
559
  server.registerTool(
506
560
  'mk_search',
507
561
  {
508
- description: 'Search kit memory (FTS5 keyword by default; semantic + hybrid require the Layer-5b semantic backend, not yet shipped).',
562
+ description: 'Search kit memory. FTS5 keyword by default; semantic + hybrid use the embedded Layer-5b backend (sqlite-vec + a local ONNX embedder — needs the optional @huggingface/transformers install).',
509
563
  inputSchema: {
510
564
  query: z.string().min(1).describe('search query'),
511
565
  mode: z.enum(['keyword', 'semantic', 'hybrid']).optional(),
566
+ scope: z.enum(['facts', 'transcripts']).optional().describe("'facts' (default) = curated memory; 'transcripts' = the raw session record — the LAST-RESORT recall tier, search it only when curated memory has no answer"),
512
567
  tier: z.enum(['U', 'P', 'L']).optional(),
513
568
  since: z.string().optional().describe('ISO 8601 timestamp'),
514
569
  limit: z.number().int().positive().max(1000).optional(),
515
570
  min_trust: z.enum(['low', 'medium', 'high']).optional(),
516
571
  },
517
572
  },
518
- makeMkSearch({ db, semanticBackend }),
573
+ makeMkSearch({ db, semanticBackend, projectRoot }),
519
574
  );
520
575
 
521
576
  // mk_get