claude-mem-lite 2.91.0 → 2.93.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/registry.mjs CHANGED
@@ -339,135 +339,3 @@ export function upsertResource(db, r) {
339
339
  return row?.id || 0;
340
340
  })();
341
341
  }
342
-
343
- /**
344
- * Get all active resources.
345
- * @param {Database} db Registry database
346
- * @returns {object[]} Array of resource objects
347
- */
348
- export function getActiveResources(db) {
349
- return db.prepare('SELECT * FROM resources WHERE status = ? ORDER BY name').all('active');
350
- }
351
-
352
- /**
353
- * Get a resource by type and name.
354
- * @param {Database} db Registry database
355
- * @param {string} type 'skill' or 'agent'
356
- * @param {string} name Resource name
357
- * @returns {object|undefined} Resource or undefined
358
- */
359
- export function getResourceByName(db, type, name) {
360
- return db.prepare('SELECT * FROM resources WHERE type = ? AND name = ?').get(type, name);
361
- }
362
-
363
- /**
364
- * Get a resource by ID.
365
- * @param {Database} db Registry database
366
- * @param {number} id Resource ID
367
- * @returns {object|undefined} Resource or undefined
368
- */
369
- export function getResourceById(db, id) {
370
- return db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
371
- }
372
-
373
- /**
374
- * Atomically increment a stats field on a resource.
375
- * @param {Database} db Registry database
376
- * @param {number} id Resource ID
377
- * @param {'recommend_count'|'adopt_count'|'success_count'} field Stats field
378
- */
379
- export function updateResourceStats(db, id, field) {
380
- const allowed = new Set(['recommend_count', 'adopt_count', 'success_count']);
381
- if (!allowed.has(field)) throw new Error(`Invalid stats field: ${field}`);
382
- // String interpolation required: SQLite cannot parameterize column names.
383
- // Safety: field is validated against allowlist above.
384
- db.prepare(`UPDATE resources SET ${field} = ${field} + 1, updated_at = datetime('now') WHERE id = ?`).run(id);
385
- }
386
-
387
- /**
388
- * Atomically increment weighted_adopt_sum by a continuous score value.
389
- * @param {Database} db Registry database
390
- * @param {number} id Resource ID
391
- * @param {number} score Adoption confidence score (0.0-1.0)
392
- */
393
- export function incrementWeightedAdopt(db, id, score) {
394
- db.prepare(`UPDATE resources SET weighted_adopt_sum = COALESCE(weighted_adopt_sum, 0) + ?, updated_at = datetime('now') WHERE id = ?`).run(score, id);
395
- }
396
-
397
- /**
398
- * Record a dispatch invocation.
399
- * @param {Database} db Registry database
400
- * @param {object} record Invocation record
401
- * @returns {number} Invocation ID
402
- */
403
- export function recordInvocation(db, record) {
404
- const result = db.prepare(`
405
- INSERT INTO invocations (resource_id, session_id, trigger, tier, recommended, adopted, outcome, score)
406
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
407
- `).run(
408
- record.resource_id, record.session_id || null,
409
- record.trigger || null, record.tier || null,
410
- record.recommended ?? 1, record.adopted ?? 0,
411
- record.outcome || null, record.score ?? null
412
- );
413
- return Number(result.lastInsertRowid);
414
- }
415
-
416
- /**
417
- * Get success rates for resources over a time period.
418
- * @param {Database} db Registry database
419
- * @param {number} [days=30] Lookback period
420
- * @returns {object[]} Array of {resource_id, total, adopted, avg_score}
421
- */
422
- export function getResourceSuccessRates(db, days = 30) {
423
- const since = new Date(Date.now() - days * 86400000).toISOString();
424
- return db.prepare(`
425
- SELECT resource_id,
426
- COUNT(*) as total,
427
- SUM(adopted) as adopted,
428
- AVG(CASE WHEN score IS NOT NULL THEN score END) as avg_score
429
- FROM invocations
430
- WHERE created_at > ?
431
- GROUP BY resource_id
432
- `).all(since);
433
- }
434
-
435
- /**
436
- * Get invocations for a specific session (for feedback collection).
437
- * @param {Database} db Registry database
438
- * @param {string} sessionId Session identifier
439
- * @returns {object[]} Array of invocation records
440
- */
441
- export function getSessionInvocations(db, sessionId) {
442
- return db.prepare(`
443
- SELECT i.*, r.name as resource_name, r.type as resource_type,
444
- r.invocation_name as invocation_name
445
- FROM invocations i
446
- JOIN resources r ON r.id = i.resource_id
447
- WHERE i.session_id = ?
448
- ORDER BY i.created_at
449
- `).all(sessionId);
450
- }
451
-
452
- /**
453
- * Update invocation outcome (for feedback).
454
- * @param {Database} db Registry database
455
- * @param {number} id Invocation ID
456
- * @param {object} update Fields to update
457
- */
458
- export function updateInvocation(db, id, update) {
459
- const allowed = new Set(['adopted', 'outcome', 'score', 'rejection_reason']);
460
- const sets = [];
461
- const vals = [];
462
- for (const [key, val] of Object.entries(update)) {
463
- if (val === undefined) continue;
464
- if (!allowed.has(key)) throw new Error(`Invalid invocation field: ${key}`);
465
- sets.push(`${key} = ?`);
466
- vals.push(val);
467
- }
468
- if (sets.length === 0) return;
469
- vals.push(id);
470
- // String interpolation required: SQLite cannot parameterize column names.
471
- // Safety: column names are validated against allowlist above.
472
- db.prepare(`UPDATE invocations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
473
- }
package/schema.mjs CHANGED
@@ -382,6 +382,7 @@ export function initSchema(db) {
382
382
 
383
383
  // FTS5 migration: recreate observations_fts when columns are missing (one-time)
384
384
  // Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
385
+ let obsFtsRecreated = false;
385
386
  try {
386
387
  const ftsDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`).get();
387
388
  if (ftsDdl && (!ftsDdl.sql.includes('lesson_learned') || !ftsDdl.sql.includes('search_aliases'))) {
@@ -389,6 +390,7 @@ export function initSchema(db) {
389
390
  db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
390
391
  db.exec(`DROP TRIGGER IF EXISTS observations_au`);
391
392
  db.exec(`DROP TABLE IF EXISTS observations_fts`);
393
+ obsFtsRecreated = true;
392
394
  }
393
395
  } catch { /* non-critical — ensureFTS will create if missing */ }
394
396
 
@@ -416,14 +418,19 @@ export function initSchema(db) {
416
418
  ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
417
419
  ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
418
420
 
419
- // Rebuild FTS5 if we just recreated it (migration populates from content table)
420
- try {
421
- const needsRebuild = db.prepare(`SELECT COUNT(*) as cnt FROM observations`).get();
422
- const ftsCount = db.prepare(`SELECT COUNT(*) as cnt FROM observations_fts`).get();
423
- if (needsRebuild.cnt > 0 && ftsCount.cnt === 0) {
424
- db.exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`);
425
- }
426
- } catch { /* non-critical */ }
421
+ // Rebuild FTS5 if we just recreated it above (the new index is empty and must be
422
+ // populated from the content table). The old emptiness probe — `SELECT COUNT(*) FROM
423
+ // observations_fts` was DEAD: for an external-content FTS5 table, COUNT reads the
424
+ // CONTENT table (observations), not the index, so `ftsCount === 0` was only ever true
425
+ // on an empty DB (where needsRebuild>0 is false). The rebuild therefore never fired and
426
+ // full-text search silently returned 0 rows after the column-mismatch migration. Gate
427
+ // on the recreation flag instead, which is the only path that leaves the index empty.
428
+ if (obsFtsRecreated) {
429
+ try {
430
+ const cnt = db.prepare(`SELECT COUNT(*) as cnt FROM observations`).get();
431
+ if (cnt.cnt > 0) db.exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`);
432
+ } catch { /* non-critical */ }
433
+ }
427
434
 
428
435
  // v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
429
436
  // triggers were hand-written inline (below) rather than via ensureFTS, so
package/search-engine.mjs CHANGED
@@ -179,7 +179,11 @@ export function countSearchTotal(db, {
179
179
  export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
180
180
  return {
181
181
  source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
182
- project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch,
182
+ // `date` is the legacy key the MCP paired-search path reads; `created_at` aligns the
183
+ // obs row shape with the session/prompt rows the CLI interleaves in the same results
184
+ // array (cmdSearch reads r.created_at uniformly) and with recent/recall output. Both
185
+ // hold the same ISO string — keep both so neither consumer breaks.
186
+ project: r.project, date: r.created_at, created_at: r.created_at, created_at_epoch: r.created_at_epoch,
183
187
  score: scoreMultiplier ? r.score * scoreMultiplier : r.score,
184
188
  files_modified: r.files_modified, importance: r.importance, lesson_learned: r.lesson_learned,
185
189
  snippet: snippet ? (r.match_snippet || '') : '',
@@ -366,6 +370,15 @@ export function searchObservationsHybrid(db, ctx) {
366
370
  if (vecResults.length === 0) return results;
367
371
 
368
372
  if (results.length > 0) {
373
+ // RRF fuses by RANK (array index), so the BM25 side must already be in
374
+ // composite-score order. `results` here is [full-FTS sorted, …concept ×0.7,
375
+ // …PRF ×0.6] with augmentation rows APPENDED, so its index order is only
376
+ // BM25-rank for the first block — a downweighted PRF row at the tail would be
377
+ // handed to RRF as a worse rank than its score warrants, and a strong one as
378
+ // better. Sort by the calibrated composite score (negative = more relevant)
379
+ // first so index == composite rank and the type-quality/decay/cite multipliers
380
+ // actually shape the fused ranking instead of being discarded by insertion order.
381
+ results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
369
382
  const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
370
383
  const resultMap = new Map(results.map(r => [r.id, r]));
371
384
  for (const vr of vecResults) {
package/secret-scrub.mjs CHANGED
@@ -18,8 +18,17 @@ export const SECRET_PATTERNS = [
18
18
  // 2. Structured keys (api_key, auth_token, …) keep the original behavior —
19
19
  // a separator/compound key is unambiguous config syntax even when
20
20
  // preceded by prose ("see auth_token: shhhhhh").
21
- [/((?<![A-Za-z][ \t])\b(?:password|passwd|token|bearer)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
22
- [/(\b(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
21
+ // `(?:\b|_)` before the keyword: a plain word-boundary misses the single most
22
+ // common credential shape — underscore-cased env vars (DB_PASSWORD, GH_TOKEN,
23
+ // MY_AUTH_TOKEN) — because `_` is a \w char, so there is NO \b between it and the
24
+ // keyword. Allowing a leading `_` catches those while the prose lookbehind still
25
+ // excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
26
+ // value is covered (the hex-only assignment pattern below misses non-hex values).
27
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
28
+ // access_token / refresh_token are the canonical OAuth2 field names — they were
29
+ // missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
30
+ // underscore-prefix reason.
31
+ [/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
23
32
  // AWS access keys (AKIA...)
24
33
  [/\bAKIA[A-Z0-9]{16}\b/g, '***'],
25
34
  // OpenAI / Anthropic keys (sk-...) — specific prefixes have lower length threshold
@@ -56,7 +65,7 @@ export const SECRET_PATTERNS = [
56
65
  // as `{"api_key": "..."}`. The base key=value pattern stops at quotes, so
57
66
  // these slip through. Match the value-quoted form explicitly. Length floor
58
67
  // (6) avoids tripping on intentional placeholder shorts ("...", "secret").
59
- [/("(?:password|passwd|token|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer|refresh[_-]?token|session[_-]?id|sessionid)"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
68
+ [/("(?:password|passwd|token|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|access[_-]?token|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer|refresh[_-]?token|session[_-]?id|sessionid)"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
60
69
  // Session cookies in headers / urlencoded bodies (sessionid=, session_id=, JSESSIONID=, PHPSESSID=).
61
70
  // 16+ chars filters out short test fixtures like sessionid=abc.
62
71
  [/\b((?:session[_-]?id|sessionid|jsessionid|phpsessid)\s*[=:]\s*)[^\s,;'"}\]]{16,}/gi, '$1***'],
package/server.mjs CHANGED
@@ -15,6 +15,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
15
15
  import {
16
16
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
17
17
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
18
+ recoverChildrenOf,
18
19
  OP_CAP, STALE_AGE_MS,
19
20
  } from './lib/maintain-core.mjs';
20
21
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
@@ -926,13 +927,20 @@ server.registerTool(
926
927
  db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
927
928
  }
928
929
  }
930
+ // Resurface rows merged/compressed INTO the doomed keepers before deleting, else
931
+ // they dangle behind a now-missing parent (compressed_into has no FK) — invisible
932
+ // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
933
+ // delete path + the maintain hard-delete guard (recoverChildrenOf).
934
+ const recovered = recoverChildrenOf(db, args.ids);
929
935
  // Execute deletion (FTS5 cleanup handled by observations_ad trigger)
930
- return db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
936
+ const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
937
+ return { changes: deleted.changes, recovered };
931
938
  });
932
939
  const result = deleteTx();
933
940
 
934
941
  const missing = args.ids.filter(id => !rows.some(r => r.id === id));
935
942
  const msg = [`Deleted ${result.changes} observation(s).`];
943
+ if (result.recovered > 0) msg.push(`Recovered ${result.recovered} merged/compressed child observation(s) to live.`);
936
944
  if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
937
945
  return { content: [{ type: 'text', text: msg.join(' ') }] };
938
946
  })
package/source-files.mjs CHANGED
@@ -77,6 +77,11 @@ export const SOURCE_FILES = [
77
77
  // mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
78
78
  // entry points; missing it from the manifest broke MCP saves on auto-update.
79
79
  'lib/save-observation.mjs',
80
+ // Single-source observations-table write primitives (insertObservationRow/Files/
81
+ // Vector). Statically imported by lib/save-observation.mjs and hook-llm.mjs (both
82
+ // entry-point-reachable); missing it from the manifest would break ALL saves on
83
+ // auto-update. Same single-source-of-truth pattern (see #8217).
84
+ 'lib/observation-write.mjs',
80
85
  // Shared "compress old low-value observations into weekly summaries" core.
81
86
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
82
87
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
package/tier.mjs CHANGED
@@ -44,9 +44,12 @@ export function computeTier(obs, ctx) {
44
44
  return 'working';
45
45
  }
46
46
 
47
- // Rule 5: Active if within type-specific window
47
+ // Rule 5: Active if within type-specific window. Use `<=` so the exact-millisecond
48
+ // window edge matches TIER_CASE_SQL (`created_at_epoch >= now - window`, i.e. inclusive).
49
+ // The strict `<` here disagreed with the SQL classifier by one tier at the boundary,
50
+ // despite both being documented as the same classifier.
48
51
  const activeWindow = ACTIVE_WINDOWS[obs.type] ?? DEFAULT_ACTIVE_WINDOW_MS;
49
- if (now - obs.created_at_epoch < activeWindow) return 'active';
52
+ if (now - obs.created_at_epoch <= activeWindow) return 'active';
50
53
 
51
54
  // Rule 6: Archive (fallback)
52
55
  return 'archive';
package/utils.mjs CHANGED
@@ -77,8 +77,11 @@ export function estimateTokens(text) {
77
77
  * @returns {number} Clamped integer importance (1, 2, or 3)
78
78
  */
79
79
  export function clampImportance(val) {
80
- if (typeof val !== 'number' || isNaN(val)) return 1;
81
- return Math.max(1, Math.min(3, Math.round(val)));
80
+ // Coerce numeric strings: an LLM emitting "importance":"2" (quoted) would otherwise
81
+ // collapse to 1, silently dropping its signal. Non-numeric strings → NaN → 1.
82
+ const n = typeof val === 'number' ? val : (typeof val === 'string' ? Number(val) : NaN);
83
+ if (!Number.isFinite(n)) return 1;
84
+ return Math.max(1, Math.min(3, Math.round(n)));
82
85
  }
83
86
 
84
87
  /**
@@ -267,9 +270,39 @@ export function debugCatch(e, context) {
267
270
 
268
271
  // ─── JSON Parsing ────────────────────────────────────────────────────────────
269
272
 
273
+ /**
274
+ * Extract the first brace-balanced JSON object substring from text, honoring strings
275
+ * and escapes so braces inside string values don't throw off the depth count. Returns
276
+ * null when there's no `{` or no balanced close. Used to recover a valid leading object
277
+ * when the LLM wrapped it in prose that ALSO contains braces — the greedy `{[\s\S]*}`
278
+ * fallback spans first-`{` to last-`}` and is defeated by an unrelated trailing `{…}`.
279
+ */
280
+ function firstBalancedJsonObject(text) {
281
+ // Anchor on whichever structural opener comes first — `{` (object) or `[` (array) —
282
+ // so a prose-wrapped top-level array isn't truncated to its first inner object.
283
+ const braceAt = text.indexOf('{');
284
+ const brackAt = text.indexOf('[');
285
+ let start, open, close;
286
+ if (braceAt === -1 && brackAt === -1) return null;
287
+ if (brackAt !== -1 && (braceAt === -1 || brackAt < braceAt)) { start = brackAt; open = '['; close = ']'; }
288
+ else { start = braceAt; open = '{'; close = '}'; }
289
+ let depth = 0, inStr = false, esc = false;
290
+ for (let i = start; i < text.length; i++) {
291
+ const c = text[i];
292
+ if (inStr) {
293
+ if (esc) esc = false;
294
+ else if (c === '\\') esc = true;
295
+ else if (c === '"') inStr = false;
296
+ } else if (c === '"') inStr = true;
297
+ else if (c === open) depth++;
298
+ else if (c === close && --depth === 0) return text.slice(start, i + 1);
299
+ }
300
+ return null;
301
+ }
302
+
270
303
  /**
271
304
  * Parse JSON from LLM output, handling markdown fences and embedded objects.
272
- * Tries: direct parse → fenced code block → regex object extraction.
305
+ * Tries: direct parse → fenced code block → first balanced object → greedy regex.
273
306
  * @param {string} text Raw LLM output text
274
307
  * @returns {object|null} Parsed JSON object or null on failure
275
308
  */
@@ -278,6 +311,10 @@ export function parseJsonFromLLM(text) {
278
311
  try { return JSON.parse(text); } catch {}
279
312
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
280
313
  if (fenced) try { return JSON.parse(fenced[1]); } catch {}
314
+ // First balanced object — survives unfenced output wrapped in brace-containing prose.
315
+ const balanced = firstBalancedJsonObject(text);
316
+ if (balanced) try { return JSON.parse(balanced); } catch {}
317
+ // Last-resort greedy span (handles a payload that isn't the FIRST balanced object).
281
318
  const obj = text.match(/\{[\s\S]*\}/);
282
319
  if (obj) try { return JSON.parse(obj[0]); } catch {}
283
320
  return null;