@lh8ppl/claude-memory-kit 0.3.0 → 0.3.2
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 -3
- package/package.json +2 -2
- 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/decisions-journal.mjs +223 -0
- package/src/digest.mjs +89 -0
- package/src/doctor.mjs +62 -3
- package/src/forget.mjs +6 -0
- 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/search.mjs +105 -2
- package/src/semantic-backend.mjs +114 -0
- package/src/subcommands.mjs +300 -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/search.mjs
CHANGED
|
@@ -136,6 +136,109 @@ function validateInput(opts) {
|
|
|
136
136
|
return { errors, mode, scope };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// --- FTS5 query sanitization (Task 153) -------------------------------
|
|
140
|
+
//
|
|
141
|
+
// FTS5's MATCH grammar (sqlite.org/fts5 §3) treats many characters a user
|
|
142
|
+
// would type in a natural query as operators or syntax errors:
|
|
143
|
+
// - a bareword may ONLY contain letters / digits / underscore / non-ASCII;
|
|
144
|
+
// a `.`, `-`, `:`, `+`, `^`, `(`, etc. in a bareword is a SYNTAX ERROR.
|
|
145
|
+
// - `AND` / `OR` / `NOT` (case-sensitive) are reserved boolean operators.
|
|
146
|
+
// So `cmk search v0.3` crashed (`v0` then `.3` → `.` violates the bareword
|
|
147
|
+
// grammar), and `cmk search user-explicit` parsed `-` as a column-exclude.
|
|
148
|
+
//
|
|
149
|
+
// The SQLite-sanctioned fix is to double-quote the offending token: inside a
|
|
150
|
+
// quoted string the tokenizer treats `.`/`-`/`:` as separators, so `"v0.3"`
|
|
151
|
+
// tokenizes to `v0` + `3` and matches the literal content. We quote
|
|
152
|
+
// PER-TOKEN (not the whole query) so a plain multi-word query keeps its
|
|
153
|
+
// implicit-AND semantics (better recall) rather than collapsing to a strict
|
|
154
|
+
// adjacency phrase. A token the user already quoted is left untouched.
|
|
155
|
+
//
|
|
156
|
+
// Validated against the FTS5 spec AND basic-memory's real implementation
|
|
157
|
+
// (the kit's closest FTS5 + markdown-native design analog). Full rationale:
|
|
158
|
+
// docs/research/2026-06-15-fts5-query-preparation-cross-system.md.
|
|
159
|
+
|
|
160
|
+
// A bareword that FTS5 accepts as-is: letters, digits, underscore, non-ASCII.
|
|
161
|
+
// Anything else in the token means it must be quoted to be a literal.
|
|
162
|
+
const FTS5_BAREWORD_RE = /^[\p{L}\p{N}_]+$/u;
|
|
163
|
+
const FTS5_RESERVED_WORDS = new Set(['AND', 'OR', 'NOT']);
|
|
164
|
+
|
|
165
|
+
// Quote a single token for literal FTS5 matching, escaping embedded `"`
|
|
166
|
+
// SQL-style (double it) per the spec. Used only when the token isn't a safe
|
|
167
|
+
// bareword.
|
|
168
|
+
function quoteFtsToken(token) {
|
|
169
|
+
return `"${token.replace(/"/g, '""')}"`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Transform a raw user query into an FTS5-safe MATCH string.
|
|
174
|
+
*
|
|
175
|
+
* Per-token: a safe bareword passes through untouched (preserving
|
|
176
|
+
* implicit-AND between words); a token with FTS5-special characters or a
|
|
177
|
+
* bare reserved word (AND/OR/NOT) is double-quoted (literal). A token the
|
|
178
|
+
* user already wrapped in `"…"` is preserved verbatim — explicit phrase
|
|
179
|
+
* search still works for power users.
|
|
180
|
+
*
|
|
181
|
+
* Exported for isolated unit testing (like reciprocalRankFusion).
|
|
182
|
+
*
|
|
183
|
+
* @param {string} raw the user's query
|
|
184
|
+
* @returns {string} an FTS5-safe MATCH expression ('' for empty input)
|
|
185
|
+
*/
|
|
186
|
+
export function prepareFtsQuery(raw) {
|
|
187
|
+
if (typeof raw !== 'string') return '';
|
|
188
|
+
const trimmed = raw.trim();
|
|
189
|
+
if (trimmed === '') return '';
|
|
190
|
+
|
|
191
|
+
return tokenizeQuery(trimmed)
|
|
192
|
+
.map((token) => {
|
|
193
|
+
// Already a user-quoted phrase (`"…"`, possibly multi-word): leave it
|
|
194
|
+
// exactly as typed — explicit phrase search still works for power users.
|
|
195
|
+
if (token.length >= 2 && token.startsWith('"') && token.endsWith('"')) {
|
|
196
|
+
return token;
|
|
197
|
+
}
|
|
198
|
+
// Safe bareword that isn't a reserved operator: pass through.
|
|
199
|
+
if (FTS5_BAREWORD_RE.test(token) && !FTS5_RESERVED_WORDS.has(token)) {
|
|
200
|
+
return token;
|
|
201
|
+
}
|
|
202
|
+
// Everything else (special chars, or a bare AND/OR/NOT): quote literal.
|
|
203
|
+
return quoteFtsToken(token);
|
|
204
|
+
})
|
|
205
|
+
.join(' ');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Split a query into tokens, keeping a double-quoted span (which may contain
|
|
209
|
+
// spaces, e.g. `"thin routes"`) as ONE token. A naive whitespace split would
|
|
210
|
+
// tear `"thin routes"` into `"thin` + `routes"` and corrupt the quoting.
|
|
211
|
+
// Unbalanced trailing quote: the final quoted run extends to end-of-string.
|
|
212
|
+
function tokenizeQuery(query) {
|
|
213
|
+
const tokens = [];
|
|
214
|
+
let i = 0;
|
|
215
|
+
while (i < query.length) {
|
|
216
|
+
if (/\s/.test(query[i])) {
|
|
217
|
+
i += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (query[i] === '"') {
|
|
221
|
+
// A `"` at a token boundary opens a phrase span: consume up to and
|
|
222
|
+
// including the closing quote (or end-of-string if unbalanced).
|
|
223
|
+
let j = i + 1;
|
|
224
|
+
while (j < query.length && query[j] !== '"') j += 1;
|
|
225
|
+
const end = j < query.length ? j + 1 : query.length;
|
|
226
|
+
tokens.push(query.slice(i, end));
|
|
227
|
+
i = end;
|
|
228
|
+
} else {
|
|
229
|
+
// A run of non-space characters. A `"` that appears MID-run (e.g.
|
|
230
|
+
// `he"llo`) is part of this token, NOT a phrase delimiter — it'll be
|
|
231
|
+
// escaped + quoted as a literal by prepareFtsQuery. Only whitespace
|
|
232
|
+
// ends the run.
|
|
233
|
+
let j = i;
|
|
234
|
+
while (j < query.length && !/\s/.test(query[j])) j += 1;
|
|
235
|
+
tokens.push(query.slice(i, j));
|
|
236
|
+
i = j;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return tokens;
|
|
240
|
+
}
|
|
241
|
+
|
|
139
242
|
// --- Keyword (FTS5 BM25) backend --------------------------------------
|
|
140
243
|
|
|
141
244
|
const KEYWORD_BASE_SQL = `
|
|
@@ -158,7 +261,7 @@ WHERE observations_fts MATCH @query
|
|
|
158
261
|
|
|
159
262
|
function buildKeywordSql(opts) {
|
|
160
263
|
const clauses = [];
|
|
161
|
-
const params = { query: opts.query };
|
|
264
|
+
const params = { query: prepareFtsQuery(opts.query) };
|
|
162
265
|
if (opts.tier !== undefined) {
|
|
163
266
|
clauses.push('o.tier = @tier');
|
|
164
267
|
params.tier = opts.tier;
|
|
@@ -265,7 +368,7 @@ function runTranscriptKeywordSearch(db, opts) {
|
|
|
265
368
|
try {
|
|
266
369
|
rows = db
|
|
267
370
|
.prepare(TRANSCRIPT_KEYWORD_SQL)
|
|
268
|
-
.all({ query: opts.query, limit: opts.limit ?? DEFAULT_LIMIT });
|
|
371
|
+
.all({ query: prepareFtsQuery(opts.query), limit: opts.limit ?? DEFAULT_LIMIT });
|
|
269
372
|
} catch (err) {
|
|
270
373
|
if (err?.code === 'SQLITE_ERROR' || /fts5:|no such column:/i.test(err?.message ?? '')) {
|
|
271
374
|
throw new FTS5ParseError(err, opts.query);
|
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 {
|