@lh8ppl/claude-memory-kit 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -10
- package/bin/cmk-capture-prompt.mjs +21 -1
- package/package.json +2 -1
- package/src/auto-extract.mjs +68 -11
- package/src/capture-prompt.mjs +33 -1
- package/src/capture-turn.mjs +64 -6
- package/src/conflict-queue.mjs +20 -3
- package/src/forget.mjs +13 -0
- package/src/frontmatter.mjs +4 -1
- package/src/import-anthropic-memory.mjs +25 -1
- package/src/index-db.mjs +39 -0
- package/src/index-rebuild.mjs +42 -2
- package/src/inject-context.mjs +49 -6
- package/src/install.mjs +107 -1
- package/src/mcp-server.mjs +57 -7
- package/src/merge-facts.mjs +12 -0
- package/src/provenance.mjs +4 -0
- package/src/result-shapes.mjs +1 -1
- package/src/scratchpad.mjs +5 -3
- package/src/search.mjs +96 -9
- package/src/semantic-backend.mjs +485 -0
- package/src/settings-hooks.mjs +4 -1
- package/src/subcommands.mjs +92 -16
- package/src/transcript-index.mjs +162 -0
- package/src/turn-tools.mjs +179 -0
- package/template/.claude/skills/memory-search/SKILL.md +86 -0
- package/template/CLAUDE.md.template +2 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// Layer 5b — the embedded semantic backend (Task 65, design §9.3.1 resolved).
|
|
2
|
+
//
|
|
3
|
+
// Architecture (the D-72 recipe on our stack):
|
|
4
|
+
// - Vectors live INSIDE the kit's existing SQLite index (sqlite-vec vec0
|
|
5
|
+
// virtual table) — one store, no server, no second index to sync.
|
|
6
|
+
// - The embedder is a LOCAL ONNX model via @huggingface/transformers
|
|
7
|
+
// (Node-native; Anthropic has no embeddings API). The dependency is
|
|
8
|
+
// OPTIONAL (~258 MB with onnxruntime): this module lazy-imports it and
|
|
9
|
+
// degrades to a clear "not installed" reason — keyword FTS5 stays the
|
|
10
|
+
// always-available default (claude-mem precedent, §9.3.1).
|
|
11
|
+
// - Embeddings are CONTENT-ADDRESSED (memweave pattern): sha256(model +
|
|
12
|
+
// body) → vector in `embedding_cache`; re-syncs embed only new/changed
|
|
13
|
+
// observations. The vec table mirrors `observations` rowids, so the
|
|
14
|
+
// §9.2.1 mutation propagation (reindexBoot before every search) flows
|
|
15
|
+
// straight into `syncSemanticIndex` — changed rows re-embed, deleted/
|
|
16
|
+
// tombstoned rows drop out of the vec table.
|
|
17
|
+
//
|
|
18
|
+
// Async boundary (deliberate): `search()` is synchronous and its
|
|
19
|
+
// `semanticBackend` DI seam is a SYNC function (Task 120 kept it that way on
|
|
20
|
+
// purpose). Embedding a query is async. So the async work happens in
|
|
21
|
+
// `prepareSemanticBackend()` — it embeds the QUERY up front and returns a
|
|
22
|
+
// sync closure over the query vector for the seam. `search()`'s public
|
|
23
|
+
// contract is untouched.
|
|
24
|
+
//
|
|
25
|
+
// Observation granularity = embedding granularity: kit observations are
|
|
26
|
+
// already small (bullets ≤200 chars, fact bodies ≤1500) — each indexed row
|
|
27
|
+
// is one embedding; no further chunking needed at kit scale (the memsearch
|
|
28
|
+
// ≤1500-char chunking rule is satisfied by construction).
|
|
29
|
+
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
// The D-105 ladder's WINNER (bake-off 2026-06-10, bench:recall on the Task-99
|
|
35
|
+
// corpus): bge-base-en-v1.5 — R@5 0.941 / paraphrase 1.000 in semantic mode,
|
|
36
|
+
// vs bge-small 0.824/0.900 and bge-m3 0.765/0.800 (the multilingual giant
|
|
37
|
+
// LOSES to the English-tuned base on short memory facts — the ladder found
|
|
38
|
+
// its ceiling at rung 2). 768-dim, ~110 MB q8 ONNX download on first use,
|
|
39
|
+
// cached by transformers.js. Dims are model-derived at sync time.
|
|
40
|
+
export const DEFAULT_MODEL_ID = 'Xenova/bge-base-en-v1.5';
|
|
41
|
+
export const DEFAULT_DIMS = 768;
|
|
42
|
+
|
|
43
|
+
// Module-level extractor cache: the ONNX session costs ~seconds to build;
|
|
44
|
+
// one per (process, model).
|
|
45
|
+
const extractorCache = new Map();
|
|
46
|
+
|
|
47
|
+
async function loadExtractor(modelId) {
|
|
48
|
+
if (extractorCache.has(modelId)) return extractorCache.get(modelId);
|
|
49
|
+
// Lazy optional import — the kit does NOT declare this dependency
|
|
50
|
+
// (install weight; §9.3.1 vector-optional). Resolution order: the
|
|
51
|
+
// project's node_modules, then global. Failure → a typed reason.
|
|
52
|
+
let pipeline;
|
|
53
|
+
try {
|
|
54
|
+
({ pipeline } = await import('@huggingface/transformers'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const extractor = await pipeline('feature-extraction', modelId, { dtype: 'q8' });
|
|
59
|
+
extractorCache.set(modelId, extractor);
|
|
60
|
+
return extractor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sha256(text) {
|
|
64
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toBlob(floatArray) {
|
|
68
|
+
return Buffer.from(new Float32Array(floatArray).buffer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Task 104.2 (D-117) — semantic scopes. Each scope pairs a vec table with
|
|
72
|
+
// the content table its rowids reference. The embedding_cache is SHARED
|
|
73
|
+
// (content-addressed: sha256(model+body) — the same text embeds once no
|
|
74
|
+
// matter which scope holds it).
|
|
75
|
+
const SEMANTIC_SCOPES = Object.freeze({
|
|
76
|
+
facts: {
|
|
77
|
+
vecTable: 'vec_observations',
|
|
78
|
+
liveSql:
|
|
79
|
+
'SELECT rowid, body FROM observations WHERE deleted_at IS NULL AND superseded_by IS NULL',
|
|
80
|
+
},
|
|
81
|
+
transcripts: {
|
|
82
|
+
vecTable: 'vec_transcripts',
|
|
83
|
+
liveSql: 'SELECT rowid, body FROM transcript_chunks',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export function ensureSemanticSchema(db, { dims = DEFAULT_DIMS } = {}) {
|
|
88
|
+
// sqlite-vec is a tiny prebuilt extension (regular dependency).
|
|
89
|
+
// Loading twice is a no-op-safe guard via function probe.
|
|
90
|
+
try {
|
|
91
|
+
db.prepare('SELECT vec_version() AS v').get();
|
|
92
|
+
} catch {
|
|
93
|
+
throw new SqliteVecNotLoadedError();
|
|
94
|
+
}
|
|
95
|
+
db.exec(`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS embedding_cache (
|
|
97
|
+
content_sha TEXT PRIMARY KEY,
|
|
98
|
+
model TEXT NOT NULL,
|
|
99
|
+
vector BLOB NOT NULL
|
|
100
|
+
);
|
|
101
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_observations USING vec0(
|
|
102
|
+
embedding float[${dims}]
|
|
103
|
+
);
|
|
104
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_transcripts USING vec0(
|
|
105
|
+
embedding float[${dims}]
|
|
106
|
+
);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS vec_meta (
|
|
108
|
+
key TEXT PRIMARY KEY,
|
|
109
|
+
value TEXT NOT NULL
|
|
110
|
+
);
|
|
111
|
+
`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class SqliteVecNotLoadedError extends Error {
|
|
115
|
+
constructor() {
|
|
116
|
+
super('sqlite-vec extension is not loaded on this db connection');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadSqliteVec(db) {
|
|
121
|
+
// Separate from ensureSemanticSchema so callers that only READ can skip
|
|
122
|
+
// schema DDL. sqlite-vec ships per-platform prebuilds; load() picks one.
|
|
123
|
+
// Idempotent: probe before loading (loading twice on one connection
|
|
124
|
+
// would re-register the extension).
|
|
125
|
+
try {
|
|
126
|
+
db.prepare('SELECT vec_version() AS v').get();
|
|
127
|
+
return Promise.resolve(true);
|
|
128
|
+
} catch {
|
|
129
|
+
// not loaded yet — fall through to the real load
|
|
130
|
+
}
|
|
131
|
+
return import('sqlite-vec').then((m) => {
|
|
132
|
+
m.load(db);
|
|
133
|
+
return true;
|
|
134
|
+
}).catch(() => false);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Incrementally sync the vec table against `observations`. Embeds only
|
|
139
|
+
* rows whose content hash misses the cache (content-addressed); removes
|
|
140
|
+
* vec rows for deleted/tombstoned observations. Returns counts.
|
|
141
|
+
*/
|
|
142
|
+
export async function syncSemanticIndex({ db, modelId = DEFAULT_MODEL_ID, dims = null, scope = 'facts' }) {
|
|
143
|
+
const scopeDef = SEMANTIC_SCOPES[scope];
|
|
144
|
+
if (!scopeDef) return { ok: false, reason: `unknown-scope:${scope}` };
|
|
145
|
+
// Public boundary in its own right — load the vec extension if this
|
|
146
|
+
// connection doesn't have it yet (prepareSemanticBackend also loads it;
|
|
147
|
+
// both entries must be self-sufficient).
|
|
148
|
+
const vecLoaded = await loadSqliteVec(db);
|
|
149
|
+
if (!vecLoaded) {
|
|
150
|
+
return { ok: false, reason: 'sqlite-vec-unavailable' };
|
|
151
|
+
}
|
|
152
|
+
const extractor = await loadExtractor(modelId);
|
|
153
|
+
if (!extractor) {
|
|
154
|
+
return { ok: false, reason: 'embedder-not-installed' };
|
|
155
|
+
}
|
|
156
|
+
// Dims are MODEL-DERIVED (bge-small 384, bge-base 768, bge-m3 1024 — the
|
|
157
|
+
// D-105 ladder changes models, and a vec0 table's dims are fixed at
|
|
158
|
+
// creation). Probe once per sync; recreate the vec table when the model
|
|
159
|
+
// OR its dims change (different vector space either way).
|
|
160
|
+
if (dims == null) {
|
|
161
|
+
const probe = await extractor('dims probe', { pooling: 'mean', normalize: true });
|
|
162
|
+
dims = probe.tolist()[0].length;
|
|
163
|
+
}
|
|
164
|
+
ensureSemanticSchema(db, { dims });
|
|
165
|
+
|
|
166
|
+
// Model/dims change invalidates BOTH scopes' vec tables (different space).
|
|
167
|
+
const meta = db.prepare("SELECT value FROM vec_meta WHERE key = 'model'").get();
|
|
168
|
+
const dimsMeta = db.prepare("SELECT value FROM vec_meta WHERE key = 'dims'").get();
|
|
169
|
+
if ((meta && meta.value !== modelId) || (dimsMeta && Number(dimsMeta.value) !== dims)) {
|
|
170
|
+
db.exec('DROP TABLE IF EXISTS vec_observations; DROP TABLE IF EXISTS vec_transcripts;');
|
|
171
|
+
ensureSemanticSchema(db, { dims });
|
|
172
|
+
}
|
|
173
|
+
const putMeta = db.prepare(
|
|
174
|
+
'INSERT INTO vec_meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
|
175
|
+
);
|
|
176
|
+
putMeta.run('model', modelId);
|
|
177
|
+
putMeta.run('dims', String(dims));
|
|
178
|
+
|
|
179
|
+
const live = db.prepare(scopeDef.liveSql).all();
|
|
180
|
+
|
|
181
|
+
// Drop vec rows that no longer correspond to live content rows.
|
|
182
|
+
const liveRowids = new Set(live.map((r) => BigInt(r.rowid)));
|
|
183
|
+
const vecRows = db.prepare(`SELECT rowid FROM ${scopeDef.vecTable}`).all();
|
|
184
|
+
const dropStmt = db.prepare(`DELETE FROM ${scopeDef.vecTable} WHERE rowid = ?`);
|
|
185
|
+
let dropped = 0;
|
|
186
|
+
for (const r of vecRows) {
|
|
187
|
+
if (!liveRowids.has(BigInt(r.rowid))) {
|
|
188
|
+
dropStmt.run(BigInt(r.rowid));
|
|
189
|
+
dropped += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Content-addressed embed: only rows whose (model+body) hash is uncached
|
|
194
|
+
// OR whose vec row is missing/stale get (re)embedded/(re)inserted.
|
|
195
|
+
const cacheGet = db.prepare('SELECT vector FROM embedding_cache WHERE content_sha = ?');
|
|
196
|
+
const cachePut = db.prepare(
|
|
197
|
+
'INSERT OR REPLACE INTO embedding_cache(content_sha, model, vector) VALUES (?, ?, ?)',
|
|
198
|
+
);
|
|
199
|
+
const vecGet = db.prepare(`SELECT rowid FROM ${scopeDef.vecTable} WHERE rowid = ?`);
|
|
200
|
+
const vecDel = db.prepare(`DELETE FROM ${scopeDef.vecTable} WHERE rowid = ?`);
|
|
201
|
+
const vecPut = db.prepare(`INSERT INTO ${scopeDef.vecTable}(rowid, embedding) VALUES (?, ?)`);
|
|
202
|
+
|
|
203
|
+
const toEmbed = [];
|
|
204
|
+
const plans = []; // {rowid, sha, cached?}
|
|
205
|
+
for (const row of live) {
|
|
206
|
+
const sha = sha256(`${modelId}\n${row.body}`);
|
|
207
|
+
const cached = cacheGet.get(sha);
|
|
208
|
+
plans.push({ rowid: BigInt(row.rowid), sha, cached: cached?.vector ?? null, body: row.body });
|
|
209
|
+
if (!cached) toEmbed.push(row.body);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let embedded = 0;
|
|
213
|
+
let vectorsBySha = new Map();
|
|
214
|
+
if (toEmbed.length > 0) {
|
|
215
|
+
// ONE batched extractor call (transformers.js batches in a single
|
|
216
|
+
// forward pass — the dominant cost is per-call, not per-text).
|
|
217
|
+
const out = await extractor(toEmbed, { pooling: 'mean', normalize: true });
|
|
218
|
+
const list = out.tolist();
|
|
219
|
+
let i = 0;
|
|
220
|
+
for (const plan of plans) {
|
|
221
|
+
if (plan.cached) continue;
|
|
222
|
+
const vec = list[i++];
|
|
223
|
+
const blob = toBlob(vec);
|
|
224
|
+
cachePut.run(plan.sha, modelId, blob);
|
|
225
|
+
vectorsBySha.set(plan.sha, blob);
|
|
226
|
+
embedded += 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let upserted = 0;
|
|
231
|
+
for (const plan of plans) {
|
|
232
|
+
const blob = plan.cached ?? vectorsBySha.get(plan.sha);
|
|
233
|
+
if (!blob) continue;
|
|
234
|
+
// vec0 has no UPSERT; delete+insert only when absent or content changed.
|
|
235
|
+
// Cheap presence probe; content change implies a NEW sha (content-
|
|
236
|
+
// addressed), which implies the row was just embedded → refresh it.
|
|
237
|
+
const present = vecGet.get(plan.rowid);
|
|
238
|
+
if (present && plan.cached) {
|
|
239
|
+
continue; // unchanged + already in the vec table
|
|
240
|
+
}
|
|
241
|
+
if (present) vecDel.run(plan.rowid);
|
|
242
|
+
vecPut.run(plan.rowid, blob);
|
|
243
|
+
upserted += 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { ok: true, embedded, upserted, dropped, total: live.length };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The async entry the CLI/MCP callers use. Embeds the QUERY, syncs the vec
|
|
251
|
+
* index, and returns a SYNC `backend` function matching the search() DI
|
|
252
|
+
* seam contract: (opts) => [{id, snippet, source_file, source_line, tier,
|
|
253
|
+
* trust, score}] — score in [0,1], higher = closer.
|
|
254
|
+
*/
|
|
255
|
+
export async function prepareSemanticBackend({
|
|
256
|
+
db,
|
|
257
|
+
query,
|
|
258
|
+
modelId = DEFAULT_MODEL_ID,
|
|
259
|
+
dims = null,
|
|
260
|
+
overFetch = 3,
|
|
261
|
+
scope = 'facts',
|
|
262
|
+
}) {
|
|
263
|
+
if (!SEMANTIC_SCOPES[scope]) {
|
|
264
|
+
return { ok: false, reason: `unknown-scope:${scope}` };
|
|
265
|
+
}
|
|
266
|
+
// User control: force-disable the semantic layer (e.g. block the one-time
|
|
267
|
+
// model download on a metered machine, or pin keyword-only behavior).
|
|
268
|
+
// Also the deterministic test hook for the absent-backend error contract.
|
|
269
|
+
if (process.env.CMK_DISABLE_SEMANTIC === '1') {
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
reason: 'disabled-by-env',
|
|
273
|
+
hint: 'CMK_DISABLE_SEMANTIC=1 is set — unset it to enable semantic/hybrid search.',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const vecLoaded = await loadSqliteVec(db).catch(() => false);
|
|
277
|
+
if (!vecLoaded) {
|
|
278
|
+
return { ok: false, reason: 'sqlite-vec-unavailable' };
|
|
279
|
+
}
|
|
280
|
+
const extractor = await loadExtractor(modelId);
|
|
281
|
+
if (!extractor) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason: 'embedder-not-installed',
|
|
285
|
+
hint:
|
|
286
|
+
'semantic search needs the optional local embedder — install it with: npm install -g @huggingface/transformers ' +
|
|
287
|
+
'(~260 MB incl. ONNX runtime; the model itself downloads once on first use). Keyword search works without it.',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const sync = await syncSemanticIndex({ db, modelId, dims, scope });
|
|
291
|
+
if (!sync.ok) return { ok: false, reason: sync.reason };
|
|
292
|
+
|
|
293
|
+
const qOut = await extractor(query, { pooling: 'mean', normalize: true });
|
|
294
|
+
const qBlob = toBlob(qOut.tolist()[0]);
|
|
295
|
+
|
|
296
|
+
const backend =
|
|
297
|
+
scope === 'transcripts'
|
|
298
|
+
? (opts = {}) => {
|
|
299
|
+
const limit = opts.limit ?? 20;
|
|
300
|
+
// No post-filters in this scope (chunks carry no tier/trust/dates
|
|
301
|
+
// — search() rejects those filters up front), so no over-fetch.
|
|
302
|
+
const rows = db
|
|
303
|
+
.prepare(
|
|
304
|
+
`SELECT m.distance AS distance,
|
|
305
|
+
t.source_file, t.source_line, t.heading, t.body
|
|
306
|
+
FROM (SELECT rowid, distance FROM vec_transcripts
|
|
307
|
+
WHERE embedding MATCH ? ORDER BY distance LIMIT ?) m
|
|
308
|
+
JOIN transcript_chunks t ON t.rowid = m.rowid
|
|
309
|
+
ORDER BY m.distance`,
|
|
310
|
+
)
|
|
311
|
+
.all(qBlob, limit);
|
|
312
|
+
return rows.map((r) => ({
|
|
313
|
+
// The synthetic T: id — search()'s transcript keyword backend
|
|
314
|
+
// produces the same key, so hybrid RRF fuses correctly.
|
|
315
|
+
id: `T:${r.source_file}:${r.source_line}`,
|
|
316
|
+
// Flatten + bound like the keyword side: raw turn bodies are
|
|
317
|
+
// multi-line and up to 1500 chars — too heavy for a result line.
|
|
318
|
+
snippet: (() => {
|
|
319
|
+
const flat = String(r.body ?? '').replace(/\s+/g, ' ').trim();
|
|
320
|
+
return flat.length > 240 ? flat.slice(0, 240) + '…' : flat;
|
|
321
|
+
})(),
|
|
322
|
+
source_file: r.source_file,
|
|
323
|
+
source_line: r.source_line,
|
|
324
|
+
heading: r.heading,
|
|
325
|
+
score: Math.max(0, 1 - r.distance / 2),
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
: (opts = {}) => {
|
|
329
|
+
const limit = opts.limit ?? 20;
|
|
330
|
+
// Over-fetch (D-72: ~3×) so post-filters (tier/trust/since) don't
|
|
331
|
+
// starve the result list.
|
|
332
|
+
const k = Math.max(limit * overFetch, limit);
|
|
333
|
+
// KNN subquery FIRST (sqlite-vec needs MATCH + LIMIT pushed into the
|
|
334
|
+
// virtual-table scan), then join observation metadata.
|
|
335
|
+
const rows = db
|
|
336
|
+
.prepare(
|
|
337
|
+
`SELECT m.rowid AS rowid, m.distance AS distance,
|
|
338
|
+
o.id, o.body, o.source_file, o.source_line, o.tier, o.trust,
|
|
339
|
+
o.created_at, o.deleted_at
|
|
340
|
+
FROM (SELECT rowid, distance FROM vec_observations
|
|
341
|
+
WHERE embedding MATCH ? ORDER BY distance LIMIT ?) m
|
|
342
|
+
JOIN observations o ON o.rowid = m.rowid
|
|
343
|
+
ORDER BY m.distance`,
|
|
344
|
+
)
|
|
345
|
+
.all(qBlob, k);
|
|
346
|
+
|
|
347
|
+
const minTrustRank = { low: 0, medium: 1, high: 2 };
|
|
348
|
+
const filtered = rows.filter((r) => {
|
|
349
|
+
if (!opts.includeTombstoned && r.deleted_at != null) return false;
|
|
350
|
+
if (opts.tier && r.tier !== opts.tier) return false;
|
|
351
|
+
if (opts.minTrust && minTrustRank[r.trust] < minTrustRank[opts.minTrust]) return false;
|
|
352
|
+
if (opts.since) {
|
|
353
|
+
const sinceMs = Date.parse(opts.since);
|
|
354
|
+
if (Number.isFinite(sinceMs) && r.created_at * 1000 < sinceMs) return false;
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return filtered.slice(0, limit).map((r) => ({
|
|
360
|
+
id: r.id,
|
|
361
|
+
snippet: r.body,
|
|
362
|
+
source_file: r.source_file,
|
|
363
|
+
source_line: r.source_line,
|
|
364
|
+
tier: r.tier,
|
|
365
|
+
trust: r.trust,
|
|
366
|
+
// cosine distance (normalized vectors) ∈ [0,2] → similarity ∈ [0,1].
|
|
367
|
+
score: Math.max(0, 1 - r.distance / 2),
|
|
368
|
+
created_at: r.created_at,
|
|
369
|
+
}));
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return { ok: true, backend, sync };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- Task 46: default-mode resolution + install-time warm-up ---------------
|
|
376
|
+
|
|
377
|
+
const VALID_DEFAULT_MODES = new Set(['keyword', 'semantic', 'hybrid']);
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The project's default search mode (Task 46): `context/settings.json` →
|
|
381
|
+
* `search.default_mode`. Written by `cmk install --with-semantic` (hybrid) /
|
|
382
|
+
* `--no-semantic` (keyword); absent/invalid → 'keyword' (the status-quo
|
|
383
|
+
* default — no surprise model downloads on machines that never opted in).
|
|
384
|
+
*/
|
|
385
|
+
export function resolveDefaultSearchMode({ projectRoot }) {
|
|
386
|
+
try {
|
|
387
|
+
const p = join(projectRoot, 'context', 'settings.json');
|
|
388
|
+
if (!existsSync(p)) return 'keyword';
|
|
389
|
+
const mode = JSON.parse(readFileSync(p, 'utf8'))?.search?.default_mode;
|
|
390
|
+
return VALID_DEFAULT_MODES.has(mode) ? mode : 'keyword';
|
|
391
|
+
} catch {
|
|
392
|
+
return 'keyword';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Install-time warm-up (Task 46): load the extractor once so the one-time
|
|
398
|
+
* model download happens during `cmk install --with-semantic`, not as a
|
|
399
|
+
* surprise on the user's first search. Best-effort — failure reports a
|
|
400
|
+
* reason, never throws.
|
|
401
|
+
*/
|
|
402
|
+
export async function warmEmbedder({ modelId = DEFAULT_MODEL_ID } = {}) {
|
|
403
|
+
const t0 = Date.now();
|
|
404
|
+
try {
|
|
405
|
+
const extractor = await loadExtractor(modelId);
|
|
406
|
+
if (!extractor) return { ok: false, reason: 'embedder-not-installed' };
|
|
407
|
+
await extractor('warm-up', { pooling: 'mean', normalize: true });
|
|
408
|
+
return { ok: true, modelId, ms: Date.now() - t0 };
|
|
409
|
+
} catch (err) {
|
|
410
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- Post-fusion rerank (D-72: keyword-overlap 0.30 + temporal 0.40) -------
|
|
415
|
+
|
|
416
|
+
const RERANK_STOPWORDS = new Set([
|
|
417
|
+
'a', 'an', 'and', 'are', 'be', 'do', 'for', 'how', 'in', 'is', 'it', 'of',
|
|
418
|
+
'on', 'or', 'our', 'the', 'this', 'to', 'we', 'what', 'when', 'where',
|
|
419
|
+
'which', 'with',
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
function contentTokens(text) {
|
|
423
|
+
return new Set(
|
|
424
|
+
(text ?? '')
|
|
425
|
+
.toLowerCase()
|
|
426
|
+
.split(/[^a-z0-9]+/)
|
|
427
|
+
.filter((t) => t.length > 2 && !RERANK_STOPWORDS.has(t)),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Parse "in late May", "~2 weeks ago", "early June" style hints → a target
|
|
432
|
+
// epoch-ms, or null. Deliberately heuristic: the date boost should help
|
|
433
|
+
// temporal questions without an LLM call (MemPalace's pattern).
|
|
434
|
+
export function parseTemporalHint(query, now = Date.now()) {
|
|
435
|
+
const q = query.toLowerCase();
|
|
436
|
+
const ago = q.match(/(\d+)\s*(day|week|month)s?\s*ago/);
|
|
437
|
+
if (ago) {
|
|
438
|
+
const n = Number(ago[1]);
|
|
439
|
+
const unitMs = { day: 86_400_000, week: 604_800_000, month: 2_592_000_000 }[ago[2]];
|
|
440
|
+
return now - n * unitMs;
|
|
441
|
+
}
|
|
442
|
+
const months = ['january','february','march','april','may','june','july','august','september','october','november','december'];
|
|
443
|
+
for (let m = 0; m < 12; m++) {
|
|
444
|
+
if (q.includes(months[m])) {
|
|
445
|
+
const d = new Date(now);
|
|
446
|
+
let year = d.getUTCFullYear();
|
|
447
|
+
// A month later than "now" almost certainly refers to LAST year's.
|
|
448
|
+
if (m > d.getUTCMonth()) year -= 1;
|
|
449
|
+
let day = 15;
|
|
450
|
+
if (q.includes(`early ${months[m]}`)) day = 5;
|
|
451
|
+
if (q.includes(`late ${months[m]}`)) day = 25;
|
|
452
|
+
return Date.UTC(year, m, day);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Rerank fused results: keyword-overlap boost (weight 0.30) + temporal-
|
|
460
|
+
* proximity boost (weight 0.40, only when the query carries a date hint
|
|
461
|
+
* and the result carries created_at). Pure + deterministic (zero API) —
|
|
462
|
+
* the D-72 "~98% without LLM" stage. Results without created_at simply
|
|
463
|
+
* skip the temporal term.
|
|
464
|
+
*/
|
|
465
|
+
export function rerankResults(results, { query, now = Date.now(), temporalWindowMs = 45 * 86_400_000 } = {}) {
|
|
466
|
+
const qTokens = contentTokens(query);
|
|
467
|
+
const target = parseTemporalHint(query, now);
|
|
468
|
+
const scored = results.map((r, i) => {
|
|
469
|
+
let s = r.score ?? 0;
|
|
470
|
+
if (qTokens.size > 0) {
|
|
471
|
+
const rTokens = contentTokens(r.snippet);
|
|
472
|
+
let overlap = 0;
|
|
473
|
+
for (const t of qTokens) if (rTokens.has(t)) overlap += 1;
|
|
474
|
+
s *= 1 + 0.3 * (overlap / qTokens.size);
|
|
475
|
+
}
|
|
476
|
+
if (target != null && r.created_at != null) {
|
|
477
|
+
const diff = Math.abs(r.created_at * 1000 - target);
|
|
478
|
+
const boost = Math.max(0, 0.4 * (1 - diff / temporalWindowMs));
|
|
479
|
+
s *= 1 + boost;
|
|
480
|
+
}
|
|
481
|
+
return { ...r, score: s, _i: i };
|
|
482
|
+
});
|
|
483
|
+
scored.sort((a, b) => b.score - a.score || a._i - b._i);
|
|
484
|
+
return scored.map(({ _i, ...r }) => r);
|
|
485
|
+
}
|
package/src/settings-hooks.mjs
CHANGED
|
@@ -201,7 +201,10 @@ export function writeKitHooks(settingsPath) {
|
|
|
201
201
|
// entirely. `mcp__cmk__*` is the documented server-wildcard form
|
|
202
202
|
// (code.claude.com/docs/en/permissions — MCP section). Pairs with the
|
|
203
203
|
// `.mcp.json` server registration written by writeKitMcpServer().
|
|
204
|
-
|
|
204
|
+
// Task 133: memory-search joins memory-write — every scaffolded skill needs
|
|
205
|
+
// its own allow entry or the model's invocation trips a "Use skill?" prompt
|
|
206
|
+
// (the Task-90 class; 75.1 scaffolded the skill but missed this).
|
|
207
|
+
const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)', 'Skill(memory-search)', 'mcp__cmk__*'];
|
|
205
208
|
if (!settings.permissions || typeof settings.permissions !== 'object') {
|
|
206
209
|
settings.permissions = {};
|
|
207
210
|
}
|