@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.
- package/README.md +16 -10
- package/bin/cmk-capture-prompt.mjs +21 -1
- package/package.json +2 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-drain.mjs +17 -1
- package/src/auto-extract.mjs +72 -16
- package/src/auto-persona.mjs +86 -1
- package/src/capture-prompt.mjs +34 -1
- package/src/capture-turn.mjs +64 -6
- package/src/config-core.mjs +161 -0
- package/src/conflict-queue.mjs +20 -3
- package/src/content-hash.mjs +30 -0
- package/src/doctor.mjs +62 -3
- package/src/forget.mjs +13 -0
- package/src/frontmatter.mjs +4 -1
- package/src/import-anthropic-memory.mjs +25 -1
- package/src/import-claude-md.mjs +333 -0
- package/src/index-db.mjs +39 -0
- package/src/index-rebuild.mjs +48 -4
- package/src/index.mjs +10 -0
- package/src/inject-context.mjs +179 -7
- package/src/install.mjs +180 -1
- package/src/mcp-server.mjs +63 -8
- package/src/memory-health.mjs +229 -0
- package/src/memory-write.mjs +32 -10
- package/src/merge-facts.mjs +12 -0
- package/src/native-binding.mjs +142 -0
- package/src/poison-guard.mjs +55 -0
- package/src/provenance.mjs +4 -0
- package/src/remember-core.mjs +53 -8
- package/src/repair.mjs +20 -3
- package/src/result-shapes.mjs +1 -1
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +96 -9
- package/src/semantic-backend.mjs +599 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/subcommands.mjs +359 -42
- package/src/transcript-index.mjs +165 -0
- package/src/turn-tools.mjs +179 -0
- package/src/write-fact.mjs +34 -3
- package/template/.claude/skills/memory-search/SKILL.md +86 -0
- package/template/.gitattributes.fragment +16 -0
- package/template/CLAUDE.md.template +3 -1
package/src/inject-context.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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:
|
|
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: [
|
|
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
|
|
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
|