claude-mem-lite 3.5.0 → 3.6.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.5.0",
13
+ "version": "3.6.0",
14
14
  "source": "./",
15
15
  "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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.5.0",
3
+ "version": "3.6.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
  "author": {
6
6
  "name": "sdsrss"
package/hook.mjs CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  bumpCitationAccess,
55
55
  computeCiteRecall,
56
56
  applyCitationDecay,
57
+ recordCitationFunnel,
57
58
  hasMainThreadAssistantText,
58
59
  } from './lib/citation-tracker.mjs';
59
60
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
@@ -572,6 +573,10 @@ async function handleStop() {
572
573
  for (const id of citeBackIds) citedMain.add(id);
573
574
  const r = applyCitationDecay(db, project, injected, citedMain, sessionId);
574
575
  debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
576
+ // R1: persist this session's invocation→cite funnel row. touched =
577
+ // obs resolved this run (denominator), promoted = obs cited this run
578
+ // (numerator). Idempotent (touched is 0 on re-fire) + best-effort.
579
+ recordCitationFunnel(db, project, sessionId, r.touched, r.promoted);
575
580
  }
576
581
  }
577
582
  } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
@@ -544,3 +544,96 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
544
544
  try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
545
545
  return { promoted, demoted, touched };
546
546
  }
547
+
548
+ /**
549
+ * R1 — persist one accumulating per-session row of the invocation→cite funnel.
550
+ * Fed by applyCitationDecay's return: `injectedDelta` = obs RESOLVED this Stop
551
+ * (touched), `citedDelta` = obs CITED this Stop (promoted). Idempotent against
552
+ * Stop multi-fire by construction — a re-fired Stop re-resolves nothing (touched
553
+ * is 0 for already-decided obs), so the no-op gate below skips it. A later turn
554
+ * that resolves NEW injections accumulates onto the same (project, session) row.
555
+ *
556
+ * Unlike the per-obs cited_count/decay_seen_count counters (lifetime-cumulative,
557
+ * session breakdown lost), this preserves the per-session series that
558
+ * computeCitationFunnelTrend reads back as a trend. Telemetry only — every write
559
+ * is wrapped so a citation_log failure can never break the Stop handler.
560
+ *
561
+ * @param {import('better-sqlite3').Database} db
562
+ * @param {string} project
563
+ * @param {string} sessionId — memory_session_id of the resolved session
564
+ * @param {number} injectedDelta — obs resolved this run (applyCitationDecay.touched)
565
+ * @param {number} citedDelta — obs cited this run (applyCitationDecay.promoted)
566
+ */
567
+ export function recordCitationFunnel(db, project, sessionId, injectedDelta, citedDelta) {
568
+ if (!db || !project || !sessionId) return;
569
+ const inj = Number(injectedDelta) || 0;
570
+ if (inj <= 0) return; // nothing resolved this run → no row noise
571
+ const cited = Math.max(0, Number(citedDelta) || 0);
572
+ try {
573
+ db.prepare(`
574
+ INSERT INTO citation_log (project, memory_session_id, resolved_at, injected_n, cited_n)
575
+ VALUES (?, ?, ?, ?, ?)
576
+ ON CONFLICT(project, memory_session_id) DO UPDATE SET
577
+ injected_n = injected_n + excluded.injected_n,
578
+ cited_n = cited_n + excluded.cited_n,
579
+ resolved_at = excluded.resolved_at
580
+ `).run(project, sessionId, Date.now(), inj, cited);
581
+ } catch (e) { debugCatch(e, 'recordCitationFunnel'); }
582
+ }
583
+
584
+ /**
585
+ * R1 — read the per-session invocation→cite funnel as a windowed trend.
586
+ * `window` aggregates [now-days, now]; `prior` aggregates [now-2*days, now-days)
587
+ * so `delta_pt` shows whether invocation effectiveness is rising or falling.
588
+ * `sessions` is the most-recent `limit` rows (per-session rate for the table view).
589
+ *
590
+ * @param {import('better-sqlite3').Database} db
591
+ * @param {{days?: number, limit?: number, project?: string|null}} [opts]
592
+ * @returns {{window_days: number, sessions: Array, window: {injected:number,cited:number,rate:number}, prior: {injected:number,cited:number,rate:number}, delta_pt: number|null}}
593
+ */
594
+ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project = null } = {}) {
595
+ const rate = (cited, inj) => (inj > 0 ? cited / inj : 0);
596
+ const empty = {
597
+ window_days: days,
598
+ sessions: [],
599
+ window: { injected: 0, cited: 0, rate: 0 },
600
+ prior: { injected: 0, cited: 0, rate: 0 },
601
+ delta_pt: null,
602
+ };
603
+ if (!db) return empty;
604
+ try {
605
+ const now = Date.now();
606
+ const windowStart = now - days * 86400000;
607
+ const priorStart = now - 2 * days * 86400000;
608
+ const projClause = project ? 'AND project = ?' : '';
609
+
610
+ const sessions = db.prepare(`
611
+ SELECT project, memory_session_id, resolved_at, injected_n, cited_n
612
+ FROM citation_log
613
+ WHERE 1=1 ${projClause}
614
+ ORDER BY resolved_at DESC
615
+ LIMIT ?
616
+ `).all(...(project ? [project, limit] : [limit]))
617
+ .map(r => ({ ...r, rate: rate(r.cited_n, r.injected_n) }));
618
+
619
+ const agg = (fromTs, toTs) => {
620
+ const params = toTs === null ? [fromTs] : [fromTs, toTs];
621
+ if (project) params.push(project);
622
+ const upper = toTs === null ? '' : 'AND resolved_at < ?';
623
+ const row = db.prepare(`
624
+ SELECT COALESCE(SUM(injected_n), 0) AS injected, COALESCE(SUM(cited_n), 0) AS cited
625
+ FROM citation_log
626
+ WHERE resolved_at >= ? ${upper} ${projClause}
627
+ `).get(...params);
628
+ return { injected: row.injected, cited: row.cited, rate: rate(row.cited, row.injected) };
629
+ };
630
+
631
+ const windowAgg = agg(windowStart, null);
632
+ const priorAgg = agg(priorStart, windowStart);
633
+ const delta_pt = priorAgg.injected > 0
634
+ ? Number(((windowAgg.rate - priorAgg.rate) * 100).toFixed(1))
635
+ : null;
636
+
637
+ return { window_days: days, sessions, window: windowAgg, prior: priorAgg, delta_pt };
638
+ } catch (e) { debugCatch(e, 'computeCitationFunnelTrend'); return empty; }
639
+ }
package/mem-cli.mjs CHANGED
@@ -40,6 +40,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
40
40
  import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
41
41
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
42
42
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
43
+ import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
43
44
  import { aggregateMetrics } from './lib/metrics.mjs';
44
45
  import {
45
46
  insertDeferred, listOpenWithOrdinal, dropDeferred,
@@ -2313,8 +2314,12 @@ function cmdCitationStats(db, args) {
2313
2314
  ? `${pollutedRows.n} obs have cited_count > decay_seen_count (pre-v34 backfill — invariant holds for new data).`
2314
2315
  : null;
2315
2316
 
2317
+ // R1: per-session invocation→cite funnel trend (citation_log). Same `days` window
2318
+ // as the per-project cite rate above; funnel.prior/delta_pt show the direction.
2319
+ const funnel = computeCitationFunnelTrend(db, { days });
2320
+
2316
2321
  if (json) {
2317
- out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote }, null, 2));
2322
+ out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote, funnel }, null, 2));
2318
2323
  return;
2319
2324
  }
2320
2325
 
@@ -2325,6 +2330,28 @@ function cmdCitationStats(db, args) {
2325
2330
  out(` ${r.project.padEnd(34)} ${String(rate).padStart(6)} cited:${r.cited}/${r.resolved} at_risk:${r.at_risk}`);
2326
2331
  }
2327
2332
  out('');
2333
+
2334
+ // R1: invocation→cite funnel — per-session trend + window-vs-prior direction.
2335
+ out(`Invocation→cite funnel (recent sessions, injected→cited; rate window ${days}d):`);
2336
+ if (funnel.sessions.length === 0) {
2337
+ out(' (no resolved sessions in window)');
2338
+ } else {
2339
+ for (const s of funnel.sessions) {
2340
+ const day = s.resolved_at ? new Date(s.resolved_at).toISOString().slice(0, 10) : '—'.repeat(10);
2341
+ const pct = (s.rate * 100).toFixed(1) + '%';
2342
+ out(` ${day} ${(s.project || '').padEnd(28)} inj ${String(s.injected_n).padStart(3)} cited ${String(s.cited_n).padStart(3)} ${pct.padStart(6)}`);
2343
+ }
2344
+ }
2345
+ let trendLine = `window rate ${(funnel.window.rate * 100).toFixed(1)}% cited ${funnel.window.cited}/${funnel.window.injected}`;
2346
+ if (funnel.delta_pt === null) {
2347
+ trendLine += ' (no prior-window data)';
2348
+ } else {
2349
+ const arrow = funnel.delta_pt > 0 ? '↑' : funnel.delta_pt < 0 ? '↓' : '→';
2350
+ const sign = funnel.delta_pt > 0 ? '+' : '';
2351
+ trendLine += ` (prior ${days}d ${(funnel.prior.rate * 100).toFixed(1)}%) ${arrow} ${sign}${funnel.delta_pt}pt`;
2352
+ }
2353
+ out(trendLine);
2354
+ out('');
2328
2355
  out('Active decay queue (uncited_streak >= 2, next miss → demote):');
2329
2356
  if (decayQueue.length === 0) out(' (none)');
2330
2357
  for (const r of decayQueue) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.5.0",
3
+ "version": "3.6.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",
package/schema.mjs CHANGED
@@ -71,7 +71,15 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
71
71
  // legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
72
72
  // v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
73
73
  // MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
74
- export const CURRENT_SCHEMA_VERSION = 37;
74
+ // v38 (R1): citation_log table — per-session invocation→cite funnel telemetry. One
75
+ // accumulating row per resolved session (injected_n / cited_n), written by
76
+ // recordCitationFunnel from applyCitationDecay's touched/promoted at Stop. Turns the
77
+ // per-obs cite counters (lifetime-cumulative) into a trendable per-session series so
78
+ // `citation-stats` can answer "is memory invocation effectiveness rising or falling".
79
+ // New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS on the
80
+ // forced migration pass; LATEST_MIGRATION_COLUMN unchanged (no new column) — same
81
+ // pattern as v35/v36.
82
+ export const CURRENT_SCHEMA_VERSION = 38;
75
83
 
76
84
  // Sentinel column for the LATEST migration set. The fast-path uses this to
77
85
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -168,6 +176,15 @@ const CORE_SCHEMA = `
168
176
  created_at_epoch INTEGER,
169
177
  PRIMARY KEY (project, type, session_id)
170
178
  );
179
+
180
+ CREATE TABLE IF NOT EXISTS citation_log (
181
+ project TEXT NOT NULL,
182
+ memory_session_id TEXT NOT NULL,
183
+ resolved_at INTEGER,
184
+ injected_n INTEGER NOT NULL DEFAULT 0,
185
+ cited_n INTEGER NOT NULL DEFAULT 0,
186
+ PRIMARY KEY (project, memory_session_id)
187
+ );
171
188
  `;
172
189
 
173
190
  // Column migrations (idempotent — only swallow "duplicate column" errors)