@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.
@@ -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
+ }
@@ -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
- const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)', 'mcp__cmk__*'];
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
  }