claude-mem-lite 3.63.0 → 3.64.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/mem-cli.mjs CHANGED
@@ -4,14 +4,15 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
- import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
7
+ import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
- import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
9
  import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
11
- import { autoBoostIfNeeded, reRankWithContext } from './search-scoring.mjs';
10
+ import { reRankWithContext } from './search-scoring.mjs';
12
11
  import { searchObservationsHybrid } from './search-engine.mjs';
12
+ import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
13
+ import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
13
14
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
- import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
+ import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
15
16
  import { searchResources } from './registry-retriever.mjs';
16
17
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
17
18
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -23,7 +24,7 @@ import {
23
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
24
25
  } from './lib/maintain-core.mjs';
25
26
  import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
26
- import { deleteObservations } from './lib/delete-core.mjs';
27
+ import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
27
28
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
29
  import { computeStatsFeed } from './lib/stats-core.mjs';
29
30
  import { buildLessonNudge } from './lib/save-nudge.mjs';
@@ -45,7 +46,7 @@ import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
45
46
  import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
46
47
  import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
47
48
  import { saveObservation } from './lib/save-observation.mjs';
48
- import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
49
+ import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
49
50
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
50
51
  import { recallByFile } from './lib/recall-core.mjs';
51
52
  import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
@@ -500,7 +501,6 @@ function cmdRecall(db, args) {
500
501
  }
501
502
  }
502
503
 
503
- const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
504
504
 
505
505
  // Time-field formatting moved to cli/common.mjs so the CLI `get` and the MCP
506
506
  // `mem_get` (server.mjs) share one source and can't drift (the drift bug:
@@ -513,13 +513,8 @@ export { OBS_TIME_FIELDS, formatObsFieldValue };
513
513
  export async function cmdSearchForTest(db, args, opts) { return cmdSearch(db, args, opts); }
514
514
 
515
515
  function renderObsRows(db, ids, requestedFields) {
516
- const placeholders = ids.map(() => '?').join(',');
517
- try {
518
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
519
- autoBoostIfNeeded(db, ids);
520
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
521
-
522
- const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
516
+ // Access-bump + fetch via the shared get-core (P2-12) — single source with mem_get.
517
+ const rows = fetchObsDetail(db, ids);
523
518
  if (rows.length === 0) return null;
524
519
  const fields = requestedFields || OBS_FIELDS;
525
520
  const parts = [];
@@ -547,12 +542,15 @@ function renderSessionRows(db, ids) {
547
542
  const parts = [];
548
543
  for (const r of rows) {
549
544
  const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
550
- if (r.request) lines.push(`Request: ${r.request}`);
551
- if (r.completed) lines.push(`Completed: ${r.completed}`);
552
- if (r.investigated) lines.push(`Investigated: ${r.investigated}`);
553
- if (r.learned) lines.push(`Learned: ${r.learned}`);
554
- if (r.next_steps) lines.push(`Next steps: ${r.next_steps}`);
555
- if (r.project) lines.push(`Project: ${r.project}`);
545
+ // SESSION_DETAIL_FIELDS (get-core, P2-12): the FULL render set — the old
546
+ // 6-field subset made remaining_items/notes/files_* searchable-but-invisible.
547
+ for (const f of SESSION_DETAIL_FIELDS) {
548
+ if (f === 'id' || f === 'created_at') continue; // already in the header
549
+ const val = r[f];
550
+ if (val === null || val === undefined || val === '') continue;
551
+ const label = f[0].toUpperCase() + f.slice(1).replace(/_/g, ' ');
552
+ lines.push(`${label}: ${val}`);
553
+ }
556
554
  parts.push(lines.join('\n'));
557
555
  }
558
556
  return { text: parts.join('\n\n'), count: rows.length };
@@ -1366,53 +1364,13 @@ function cmdBrowse(db, args) {
1366
1364
  const jsonOutput = flags.json === true || flags.json === 'true';
1367
1365
  const now = Date.now();
1368
1366
 
1369
- const ctx = {
1370
- now,
1371
- currentProject: project,
1372
- currentSessionId: getActiveSessionId(db, project),
1373
- };
1374
- const params = tierSqlParams(ctx);
1375
-
1376
- const tiers = ['working', 'active', 'archive'];
1377
- const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
1378
- const showTiers = tierFilter ? [tierFilter] : tiers;
1379
-
1380
- // Collect data first (for JSON), then format. The text path also prints
1381
- // tier headers as it walks; refactored to two passes so the JSON shape can
1382
- // include row arrays alongside totals.
1383
- const tierData = {};
1384
- const tierCounts = {};
1385
- let grandTotal = 0;
1386
-
1387
- for (const tier of showTiers) {
1388
- const countRow = db.prepare(`
1389
- SELECT COUNT(*) as c FROM (
1390
- SELECT ${TIER_CASE_SQL} as tier FROM observations
1391
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1392
- ) WHERE tier = ?
1393
- `).get(...params, project, tier);
1394
- const count = countRow?.c ?? 0;
1395
- tierCounts[tier] = count;
1396
- grandTotal += count;
1397
-
1398
- // Archive in unfiltered view: keep count but skip row fetch (matches text path).
1399
- const skipRows = tier === 'archive' && !tierFilter;
1400
- if (count === 0 || skipRows) {
1401
- tierData[tier] = { count, rows: [] };
1402
- continue;
1403
- }
1404
-
1405
- const rows = db.prepare(`
1406
- SELECT * FROM (
1407
- SELECT id, type, title, importance, created_at_epoch, created_at, ${TIER_CASE_SQL} as tier
1408
- FROM observations
1409
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1410
- ) WHERE tier = ?
1411
- ORDER BY created_at_epoch DESC
1412
- LIMIT ?
1413
- `).all(...params, project, tier, limit);
1414
- tierData[tier] = { count, rows };
1415
- }
1367
+ // Shared collection (lib/browse-core, P2-12) — single source with mem_browse.
1368
+ const { showTiers, tierData, tierCounts, grandTotal } = collectBrowseTiers(db, {
1369
+ project, tierFilter, limit, now,
1370
+ currentSessionId: getActiveMemorySessionId(db, project),
1371
+ });
1372
+ const tiers = BROWSE_TIERS;
1373
+ const tierLabels = BROWSE_TIER_LABELS;
1416
1374
 
1417
1375
  if (jsonOutput) {
1418
1376
  const tiersOut = {};
@@ -1470,13 +1428,6 @@ function cmdBrowse(db, args) {
1470
1428
  }
1471
1429
  }
1472
1430
 
1473
- function getActiveSessionId(db, project) {
1474
- const row = db.prepare(
1475
- "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
1476
- ).get(project);
1477
- return row?.memory_session_id ?? '';
1478
- }
1479
-
1480
1431
  // ─── Delete ──────────────────────────────────────────────────────────────────
1481
1432
 
1482
1433
  function cmdDelete(db, args) {
@@ -1508,8 +1459,8 @@ function cmdDelete(db, args) {
1508
1459
  }
1509
1460
 
1510
1461
  const confirm = flags.confirm === true || flags.confirm === 'true';
1511
- const placeholders = ids.map(() => '?').join(',');
1512
- const rows = db.prepare(`SELECT id, type, title, project FROM observations WHERE id IN (${placeholders})`).all(...ids);
1462
+ // Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
1463
+ const { rows, lines: previewLines } = previewDeleteRows(db, ids);
1513
1464
 
1514
1465
  if (rows.length === 0) {
1515
1466
  fail('[mem] No observations found for given IDs');
@@ -1518,9 +1469,7 @@ function cmdDelete(db, args) {
1518
1469
 
1519
1470
  if (!confirm) {
1520
1471
  out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
1521
- for (const r of rows) {
1522
- out(` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
1523
- }
1472
+ for (const line of previewLines) out(line);
1524
1473
  out('[mem] Run with --confirm to execute deletion.');
1525
1474
  return;
1526
1475
  }
@@ -1569,8 +1518,7 @@ function cmdUpdate(db, args) {
1569
1518
  // (#8470). Reject cleanly via the shared guard — single source with the other commands.
1570
1519
  if (rejectBareStringFlags(flags, ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts'])) return;
1571
1520
 
1572
- const updates = [];
1573
- const params = [];
1521
+ const fields = {};
1574
1522
  if (flags.title !== undefined) {
1575
1523
  // Reject empty title — clears the observation's identifier and would render it
1576
1524
  // as `(untitled)` in every listing. Almost always an accidental shell-stripped arg.
@@ -1578,7 +1526,7 @@ function cmdUpdate(db, args) {
1578
1526
  fail('[mem] --title cannot be empty. Pass a non-empty string or omit the flag to leave the title unchanged.');
1579
1527
  return;
1580
1528
  }
1581
- updates.push('title = ?'); params.push(scrubSecrets(flags.title));
1529
+ fields.title = flags.title;
1582
1530
  }
1583
1531
  if (flags.narrative !== undefined) {
1584
1532
  // Reject empty (mirror --title): an explicit '' would blank the narrative
@@ -1587,7 +1535,7 @@ function cmdUpdate(db, args) {
1587
1535
  fail('[mem] --narrative cannot be empty. Omit the flag to leave it unchanged.');
1588
1536
  return;
1589
1537
  }
1590
- updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1538
+ fields.narrative = flags.narrative;
1591
1539
  }
1592
1540
  if (flags.type) {
1593
1541
  const validTypes = OBS_TYPE_SET;
@@ -1595,7 +1543,7 @@ function cmdUpdate(db, args) {
1595
1543
  fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
1596
1544
  return;
1597
1545
  }
1598
- updates.push('type = ?'); params.push(flags.type);
1546
+ fields.type = flags.type;
1599
1547
  }
1600
1548
  if (flags.importance) {
1601
1549
  const imp = parseInt(flags.importance, 10);
@@ -1605,7 +1553,7 @@ function cmdUpdate(db, args) {
1605
1553
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
1606
1554
  return;
1607
1555
  }
1608
- updates.push('importance = ?'); params.push(imp);
1556
+ fields.importance = imp;
1609
1557
  }
1610
1558
  if (flags.lesson !== undefined || flags['lesson-learned'] !== undefined) {
1611
1559
  const rawLesson = flags.lesson ?? flags['lesson-learned'] ?? '';
@@ -1620,8 +1568,7 @@ function cmdUpdate(db, args) {
1620
1568
  fail('[mem] --lesson cannot be empty. Omit the flag to leave it unchanged.');
1621
1569
  return;
1622
1570
  }
1623
- updates.push('lesson_learned = ?');
1624
- params.push(scrubSecrets(rawLesson));
1571
+ fields.lesson_learned = rawLesson;
1625
1572
  }
1626
1573
  // Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
1627
1574
  // mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
@@ -1631,24 +1578,18 @@ function cmdUpdate(db, args) {
1631
1578
  fail('[mem] --concepts cannot be empty. Omit the flag to leave it unchanged.');
1632
1579
  return;
1633
1580
  }
1634
- updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts));
1581
+ fields.concepts = flags.concepts;
1635
1582
  }
1636
1583
 
1637
- if (updates.length === 0) {
1584
+ // Shared mutation (lib/observation-write applyObsUpdate, P2-12): scrub + UPDATE +
1585
+ // derived-column rebuild in one transaction — single source with MCP mem_update.
1586
+ const updatedCols = applyObsUpdate(db, id, fields);
1587
+ if (updatedCols.length === 0) {
1638
1588
  fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
1639
1589
  return;
1640
1590
  }
1641
1591
 
1642
- params.push(id);
1643
-
1644
- // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1645
- // shared core — single source with MCP mem_update (lib/observation-write.mjs).
1646
- db.transaction(() => {
1647
- db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1648
- rebuildObservationDerived(db, id);
1649
- })();
1650
-
1651
- out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
1592
+ out(`[mem] Updated #${id}: ${updatedCols.join(', ')}`);
1652
1593
  }
1653
1594
 
1654
1595
  // ─── Export ──────────────────────────────────────────────────────────────────
@@ -1822,7 +1763,14 @@ function cmdRestore(db, argv) {
1822
1763
  // weekly-summary keeper already absorbed (duplicate search hits, and the marker's
1823
1764
  // target id is meaningless in this store). Reject rather than remap; the content
1824
1765
  // lives on in the keeper.
1825
- if (r.compressed_into) { tombstoned++; continue; }
1766
+ // D#122: COMPRESSED_PENDING_PURGE (-2) is NOT keeper-absorbed — those rows have
1767
+ // no keeper, so rejecting them silently loses their only copy. Restore them
1768
+ // live; the maintain decay pipeline will re-evaluate them like any other row.
1769
+ // COMPRESSED_AUTO (-1) stays REJECTED by design (review 2026-08-16): those rows
1770
+ // were deliberately retired by the quality pipeline (idle-cleanup/optimize mark
1771
+ // importance-1 aged rows) — restoring them live would resurrect adjudicated
1772
+ // noise, unlike -2 (a purge QUEUE the user may still be racing to undo).
1773
+ if (r.compressed_into && r.compressed_into !== COMPRESSED_PENDING_PURGE) { tombstoned++; continue; }
1826
1774
  const project = projOverride || r.project || inferProject();
1827
1775
  const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
1828
1776
  // Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
@@ -1889,7 +1837,7 @@ function cmdRestore(db, argv) {
1889
1837
  // ones that parsed.
1890
1838
  const totalMalformed = malformed + parseFailures;
1891
1839
  const totalLines = rows.length + parseFailures;
1892
- const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (already absorbed by their summary keeper)` : '';
1840
+ const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (keeper-absorbed or auto-retired tombstones)` : '';
1893
1841
  out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1894
1842
  // Name the lossiness where the user meets it. Export omits related_ids and drops
1895
1843
  // superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
@@ -2305,18 +2253,13 @@ function cmdRegistry(_memDb, args) {
2305
2253
  if (action === 'list') {
2306
2254
  const typeFilter = flags.type;
2307
2255
  const listLimit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
2308
- const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
2309
- const params = typeFilter ? [typeFilter, 'active'] : ['active'];
2310
- const allResources = rdb.prepare(`
2311
- SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
2312
- FROM resources ${where} ORDER BY adopt_count DESC, recommend_count DESC, type, name
2313
- `).all(...params);
2256
+ // Shared ranked query + row line (registry.mjs, P2-12): COALESCE ordering and
2257
+ // the 80-char/adopt:0 row shape had drifted between the faces.
2258
+ const allResources = listResourcesRanked(rdb, { type: typeFilter });
2314
2259
  if (allResources.length === 0) { out('[mem] No resources found.'); return; }
2315
2260
  const resources = allResources.slice(0, listLimit);
2316
2261
  out(`[mem] Resources (showing ${resources.length} of ${allResources.length}):`);
2317
- for (const r of resources) {
2318
- out(` ${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count} adopt:${r.adopt_count} — ${truncate(r.capability_summary || '', 50)}`);
2319
- }
2262
+ for (const r of resources) out(` ${formatRegistryListLine(r)}`);
2320
2263
  if (allResources.length > listLimit) {
2321
2264
  out(`[mem] Use --limit N to see more, or "registry search <query>" to find specific resources.`);
2322
2265
  }
@@ -2324,25 +2267,17 @@ function cmdRegistry(_memDb, args) {
2324
2267
  }
2325
2268
 
2326
2269
  if (action === 'stats') {
2327
- const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
2328
- const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
2329
- const topAdopted = rdb.prepare(
2330
- 'SELECT name, type, adopt_count, recommend_count FROM resources WHERE status = ? AND adopt_count > 0 ORDER BY adopt_count DESC LIMIT 10'
2331
- ).all('active');
2332
- const zeroAdopt = rdb.prepare(
2333
- 'SELECT COUNT(*) as c FROM resources WHERE status = ? AND recommend_count > 0 AND adopt_count = 0'
2334
- ).get('active');
2335
- const userAdded = rdb.prepare(
2336
- "SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'"
2337
- ).get('active');
2270
+ // Shared collection (registry.mjs collectRegistryStats, P2-12) — single
2271
+ // source with the MCP mem_registry stats action.
2272
+ const s = collectRegistryStats(rdb);
2338
2273
  out(`[mem] Registry Stats:`);
2339
- out(` Total active: ${total.c}`);
2340
- for (const t of byType) out(` ${t.type}: ${t.c}`);
2341
- out(` User-added: ${userAdded.c}`);
2342
- out(` Zero adoption (recommended but never adopted): ${zeroAdopt.c}`);
2343
- if (topAdopted.length > 0) {
2274
+ out(` Total active: ${s.total}`);
2275
+ for (const t of s.byType) out(` ${t.type}: ${t.c}`);
2276
+ out(` User-added: ${s.userAdded}`);
2277
+ out(` Zero adoption (recommended but never adopted): ${s.zeroAdopt}`);
2278
+ if (s.topAdopted.length > 0) {
2344
2279
  out(' Top adopted:');
2345
- for (const r of topAdopted) out(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
2280
+ for (const r of s.topAdopted) out(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
2346
2281
  }
2347
2282
  return;
2348
2283
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.63.0",
3
+ "version": "3.64.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.63.0",
9
+ "version": "3.64.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.63.0",
3
+ "version": "3.64.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -82,6 +82,10 @@
82
82
  "lib/atomic-write.mjs",
83
83
  "lib/release-digest.mjs",
84
84
  "lib/mem-override.mjs",
85
+ "lib/injected-ids.mjs",
86
+ "lib/inject-search-core.mjs",
87
+ "lib/get-core.mjs",
88
+ "lib/browse-core.mjs",
85
89
  "lib/save-observation.mjs",
86
90
  "lib/observation-write.mjs",
87
91
  "lib/recall-core.mjs",
package/registry.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import Database from 'better-sqlite3';
5
5
  import { existsSync, mkdirSync } from 'fs';
6
6
  import { dirname } from 'path';
7
- import { debugCatch } from './utils.mjs';
7
+ import { debugCatch, truncate } from './utils.mjs';
8
8
 
9
9
  // ─── Schema ──────────────────────────────────────────────────────────────────
10
10
 
@@ -513,3 +513,55 @@ export function upsertResource(db, r) {
513
513
  return row?.id || 0;
514
514
  })();
515
515
  }
516
+
517
+ // ─── P2-12: registry stats/list twin cores (audit 2026-08-14) ────────────────
518
+ // The five stats statements + the list row line were duplicated in mem-cli.mjs
519
+ // and server.mjs and had already drifted (truncate 50 vs 80; `adopt:null` on
520
+ // one face; the COALESCE ordering fix landed on the MCP face only). Data
521
+ // collection + row-line shape live here; faces keep their own headers/limits.
522
+
523
+ /**
524
+ * Collect the five stat groups the `registry stats` twin renders.
525
+ * @param {import('better-sqlite3').Database} rdb registry DB handle
526
+ * @returns {{total:number, byType:Array<{type:string,c:number}>, topAdopted:object[], zeroAdopt:number, userAdded:number}}
527
+ */
528
+ export function collectRegistryStats(rdb) {
529
+ const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
530
+ const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
531
+ const topAdopted = rdb.prepare(
532
+ 'SELECT name, type, adopt_count, recommend_count FROM resources WHERE status = ? AND adopt_count > 0 ORDER BY adopt_count DESC LIMIT 10'
533
+ ).all('active');
534
+ const zeroAdopt = rdb.prepare(
535
+ 'SELECT COUNT(*) as c FROM resources WHERE status = ? AND recommend_count > 0 AND adopt_count = 0'
536
+ ).get('active');
537
+ const userAdded = rdb.prepare(
538
+ "SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'"
539
+ ).get('active');
540
+ return { total: total.c, byType, topAdopted, zeroAdopt: zeroAdopt.c, userAdded: userAdded.c };
541
+ }
542
+
543
+ /**
544
+ * Ranked resource listing for the `registry list` twin: adoption first, then
545
+ * recommendation, NULL counts coalesced (the un-coalesced face sorted NULLs
546
+ * apart AND rendered "adopt:null").
547
+ * @param {import('better-sqlite3').Database} rdb
548
+ * @param {{type?: string}} [opts]
549
+ * @returns {object[]}
550
+ */
551
+ export function listResourcesRanked(rdb, { type } = {}) {
552
+ const where = type ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
553
+ const params = type ? [type, 'active'] : ['active'];
554
+ return rdb.prepare(`
555
+ SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
556
+ FROM resources ${where}
557
+ ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
558
+ `).all(...params);
559
+ }
560
+
561
+ /** One list row, shared shape: `S name (invocation) — rec:N adopt:N — summary…` (80-char summary cap).
562
+ * truncate(), not a hand-rolled slice (adversarial review 2026-08-16): the shared
563
+ * helper flattens newlines (this is a one-row-per-resource listing) and never
564
+ * splits a UTF-16 surrogate pair — a bare slice emitted lone surrogates. */
565
+ export function formatRegistryListLine(r) {
566
+ return `${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`;
567
+ }
package/scoring-sql.mjs CHANGED
@@ -23,10 +23,11 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
23
23
  // Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
24
24
  // can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
25
25
  // delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
26
- // (benchmark/ci-gate.mjs) covers the full modelled chain
27
- // (decay/type/project/importance/access/lesson); cite + noise live on the
28
- // injection path (hook-memory.mjs) with no recall-benchmark coverage. Genuine
29
- // validation of the prior-encoding axes needs a labeled real-dev-memory eval.
26
+ // (benchmark/ci-gate.mjs) covers the full modelled chain — D#121: cite + noise
27
+ // joined the matrix MULT_EXPR after M-3 put them in FULL_SCORE (fixture carries
28
+ // zero cite/noise state, so both read 0 by construction, same caveat as lesson;
29
+ // their real-SQL direction pins live in benchmark/events-pipeline-probes.mjs).
30
+ // Genuine validation of the prior-encoding axes needs a labeled real-dev-memory eval.
30
31
 
31
32
  // ─── Type-Differentiated Recency Decay ──────────────────────────────────────
32
33
 
@@ -104,7 +104,9 @@ try {
104
104
  let additionalContext;
105
105
  if (content.length > 16000) {
106
106
  const summary = defang(content.slice(0, 800));
107
- additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
107
+ // D#122 ③: the path interpolates OUTSIDE the wrapper — defang it too (a
108
+ // locally-created managed dir can carry delimiter chars in its name).
109
+ additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${defang(portablePath)}") to load full content.`;
108
110
  } else {
109
111
  additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
110
112
  }
@@ -8,6 +8,8 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
11
+ import { injectedIdsFileName } from '../lib/injected-ids.mjs';
12
+ import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
11
13
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
12
14
  import { recordHookError } from '../lib/hook-telemetry.mjs';
13
15
  import { citeFactorClause } from '../scoring-sql.mjs';
@@ -153,11 +155,14 @@ function entryTimestamp(v) {
153
155
  }
154
156
 
155
157
  // A3 (v2.83): cross-hook injected-IDs store. UPS writes
156
- // `runtime/.claude-mem-injected-<project>` with {ids, ts, count}. We read
157
- // inside the staleness window, filter overlaps from PreToolUse output, then
158
- // merge back so the next UPS sees what we emitted too.
159
- function crossHookInjectedFile(project) {
160
- return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
158
+ // `runtime/.claude-mem-injected-<project>-<session>` with {ids, ts, count}. We
159
+ // read inside the staleness window, filter overlaps from PreToolUse output,
160
+ // then merge back so the next UPS sees what we emitted too.
161
+ // D#120: the file is keyed per SESSION (payload-only keying let two concurrent
162
+ // windows clobber each other's marker). Derivation shared via lib/injected-ids.mjs
163
+ // (pure, no deps — within the standalone fast-path budget, #8447).
164
+ function crossHookInjectedFile(project, sessionId) {
165
+ return join(RUNTIME_DIR, injectedIdsFileName(project, sessionId));
161
166
  }
162
167
 
163
168
  // M-6 (audit 2026-08-14): the marker file is keyed by PROJECT, so two concurrent
@@ -168,7 +173,7 @@ function crossHookInjectedFile(project) {
168
173
  // session-key fix. Legacy payloads without `session` keep the old behavior.
169
174
  function readCrossHookInjected(project, sessionId) {
170
175
  try {
171
- const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
176
+ const raw = readFileSync(crossHookInjectedFile(project, sessionId), 'utf8');
172
177
  const { ids, ts, session } = JSON.parse(raw);
173
178
  if (session && sessionId && session !== sessionId) return new Set();
174
179
  if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
@@ -181,7 +186,7 @@ function mergeCrossHookInjected(project, newIds, sessionId) {
181
186
  if (!newIds || newIds.length === 0) return;
182
187
  try {
183
188
  mkdirSync(RUNTIME_DIR, { recursive: true });
184
- const file = crossHookInjectedFile(project);
189
+ const file = crossHookInjectedFile(project, sessionId);
185
190
  let prev = { ids: [], ts: 0, count: 0 };
186
191
  try {
187
192
  const raw = readFileSync(file, 'utf8');
@@ -428,8 +433,7 @@ try {
428
433
  JOIN observation_files of2 ON of2.obs_id = o.id
429
434
  WHERE o.project = ?
430
435
  AND o.importance >= 2
431
- AND COALESCE(o.compressed_into, 0) = 0
432
- AND o.superseded_at IS NULL
436
+ AND ${liveObsFilterSql('o')}
433
437
  AND o.created_at_epoch > ?
434
438
  AND ${fileMatch}
435
439
  ${edgeDecayFilter}
@@ -149,6 +149,9 @@ export function extractErrorSignature(text) {
149
149
 
150
150
  export const MAX_SESSION_INJECTIONS = 15;
151
151
  export const DEDUP_STALE_MS = 300_000; // 5 minutes
152
+ // Marker file-name derivation lives in lib/injected-ids.mjs (D#120) — lib/, not
153
+ // here, so hook.mjs can import it (scripts/ static imports from hook.mjs break
154
+ // under the installExtractedRelease scripts-dir rename).
152
155
 
153
156
  /**
154
157
  * Check if injection should be skipped based on deduplication state.