claude-mem-lite 3.76.2 → 3.77.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.76.2",
13
+ "version": "3.77.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.76.2",
3
+ "version": "3.77.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-memory.mjs CHANGED
@@ -407,8 +407,10 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
407
407
  // - it was the ONLY code splitting basenames on either separator, so the six
408
408
  // Windows-path tests aimed at it went green while the shipped predicate
409
409
  // carried the gap (fixed in lib/file-edge-match.mjs, same round).
410
- // Those suites now run through `matchFileEdges` (tests/test-helpers.mjs), which
411
- // calls the shipped predicate. Do not reintroduce an in-process twin here.
410
+ // Those suites now run through `fileEdgeMatchOnly` (tests/test-helpers.mjs),
411
+ // which calls the shipped MATCH clause and only that clause; the rest of the
412
+ // injection query is guarded by the subprocess cases in
413
+ // tests/pre-tool-recall.test.mjs. Do not reintroduce an in-process twin here.
412
414
 
413
415
  /**
414
416
  * Phase-2 task-imperative ranking (spec 2026-06-29 §4.1): score every candidate lesson
package/hook.mjs CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  applyCitationDecay,
63
63
  recordCitationFunnel,
64
64
  recordCitationSurfaces,
65
+ collectSubagentSurface,
65
66
  hasMainThreadAssistantText,
66
67
  } from './lib/citation-tracker.mjs';
67
68
  import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
@@ -860,6 +861,41 @@ async function handleStop() {
860
861
  const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
861
862
  writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
862
863
  } catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
864
+
865
+ // D#152: the `subagent` face. Recorded in its OWN
866
+ // recordCitationSurfaces call because it carries a different `cited`
867
+ // set — a lesson handed to a dispatched subagent is cited in that
868
+ // subagent's own transcript. Folding it into the main call would score
869
+ // subagent injections against citedMain and report 0% by
870
+ // construction; folding its cites INTO citedMain would credit the
871
+ // main-thread faces for citations the main thread never made. The
872
+ // upsert key is (project, session, surface), so two calls with
873
+ // disjoint face sets do not collide. Metering only — `subagent` is in
874
+ // NON_ATTACHMENT_SURFACES and never reaches applyCitationDecay.
875
+ //
876
+ // Placed LAST on purpose: lib/transcript-scan.mjs memoizes ONE file,
877
+ // so reading the sidechain files evicts the parent transcript. Run
878
+ // earlier, this block costs ONE extra parse of the parent — the memo
879
+ // re-caches on the first re-read, so it is one, not one per later
880
+ // scanner — and breaks the "one parse per Stop" property the block
881
+ // above documents. Measured by instrumenting the parse: 1 parent parse
882
+ // at this position, 2 when relocated earlier. ~25ms on the largest
883
+ // real transcript here (5.4MB), matching lib/transcript-scan.mjs's
884
+ // own header figure.
885
+ // The text floor is re-checked rather than inherited — same reason as
886
+ // every other face: a tool-only Stop must not bank a verdict, and it
887
+ // must not enter the funnel's session denominator either.
888
+ try {
889
+ if (hasMainThreadAssistantText(transcriptPath)) {
890
+ const sub = collectSubagentSurface(transcriptPath);
891
+ if (sub.injected.size > 0) {
892
+ recordCitationSurfaces(db, project, ccSessionId || sessionId,
893
+ { subagent: sub.injected }, sub.cited);
894
+ debugLog('DEBUG', 'handleStop',
895
+ `subagent-face: files=${sub.files} injected=${sub.injected.size} cited=${sub.cited.size}`);
896
+ }
897
+ }
898
+ } catch (e) { debugCatch(e, 'handleStop-subagent-face'); }
863
899
  }
864
900
  } catch (e) { debugCatch(e, 'handleStop-citation-track'); }
865
901
  } finally {
@@ -246,9 +246,14 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
246
246
  * imperative line — which is how it stayed unmetered since v3.23 while being a live
247
247
  * injection: the `ups` matcher gates on `<memory-context` and collects only `- [` rows.
248
248
  * The two faces therefore OVERLAP on attachments but never on ids.
249
- * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'>}
249
+ * `subagent` (v3.77, D#152) is the second one: pre-agent-inject.js appends the
250
+ * memory block to a DISPATCHED subagent's task prompt via PreToolUse
251
+ * `updatedInput`, and Claude Code writes that turn to
252
+ * `<session>/subagents/agent-*.jsonl` — never to the parent transcript. See
253
+ * NON_ATTACHMENT_SURFACES.
254
+ * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'|'subagent'>}
250
255
  */
251
- export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx'];
256
+ export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx', 'subagent'];
252
257
 
253
258
  // Single source of truth for "which attachment belongs to which face, and how
254
259
  // its ids are read off". Both the per-face extractors below AND the one-pass
@@ -499,6 +504,30 @@ const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
499
504
  /** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
500
505
  export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
501
506
 
507
+ /**
508
+ * Faces that leave NO hook attachment in the parent transcript. Because
509
+ * DECAY_DENOMINATOR_SURFACES is derived from ATTACHMENT_SURFACES, these can
510
+ * never enter the decay denominator by the derivation — they enter only if a
511
+ * call site unions them in deliberately. That silence is the danger, so each is
512
+ * named here with the reason it stays out:
513
+ *
514
+ * - `keyctx` (D#124): the SessionStart Key Context block re-renders the same
515
+ * fixed top-10 unconditionally, so an uncited render says nothing about
516
+ * relevance. handleStop feeds its ids in PROMOTION-ONLY — cited ones join the
517
+ * decay set, ignored ones never do. v3.66.0 fed them as a bare denominator
518
+ * and the block ate its own contents.
519
+ * - `subagent` (D#152): the injection lands in a dispatched subagent's PROMPT
520
+ * and its cite signal lands in that subagent's OWN transcript, so it is not
521
+ * commensurable with `citedMain` — scoring it there would mark every
522
+ * subagent-only injection uncited by construction. Metered first (this is the
523
+ * whole point of D#152: the face's cite-rate is unknown), decided second.
524
+ *
525
+ * A member here that the Stop path stops feeding becomes an all-zero face, not
526
+ * a silently-demoting one — which is the failure mode worth keeping.
527
+ * @type {ReadonlyArray<string>}
528
+ */
529
+ export const NON_ATTACHMENT_SURFACES = ['keyctx', 'subagent'];
530
+
502
531
  /**
503
532
  * Flatten a per-face breakdown into the single injected set the decay loop
504
533
  * takes. Derived — NOT a second hand-maintained face list — so adding a face to
@@ -565,6 +594,67 @@ export function extractInjectedFromSubagentPrompt(transcriptPath) {
565
594
  return ids;
566
595
  }
567
596
 
597
+ /**
598
+ * Locate the sidechain (subagent) transcripts belonging to ONE session, given
599
+ * that session's main transcript path. Claude Code lays them out as
600
+ * `<dir>/<sessionId>.jsonl` + `<dir>/<sessionId>/subagents/agent-<name>-<hash>.jsonl`,
601
+ * so the directory is derived from the parent path — no directory scan, no
602
+ * dependence on the CC session id being available at the call site.
603
+ *
604
+ * Two search shapes that look right and silently return nothing (#10801, and the
605
+ * reason D#152 sat blocked): the files are NOT at the transcript directory's top
606
+ * level, and grepping the PARENT transcript for `isSidechain` finds 0 records —
607
+ * the flag lives inside the subagent files themselves.
608
+ *
609
+ * @param {string|null|undefined} transcriptPath main-thread transcript (.jsonl)
610
+ * @returns {string[]} absolute paths, empty when the session dispatched no subagents
611
+ */
612
+ export function findSubagentTranscripts(transcriptPath) {
613
+ if (!transcriptPath || typeof transcriptPath !== 'string') return [];
614
+ if (!transcriptPath.endsWith('.jsonl')) return [];
615
+ const dir = join(transcriptPath.slice(0, -'.jsonl'.length), 'subagents');
616
+ let names;
617
+ try { names = readdirSync(dir); } catch { return []; }
618
+ return names.filter((n) => n.endsWith('.jsonl')).map((n) => join(dir, n)).sort();
619
+ }
620
+
621
+ /**
622
+ * Read the `subagent` face for ONE session: ids injected into dispatched
623
+ * subagents' prompts, and the ids those subagents actually cited.
624
+ *
625
+ * `cited` is read from the SUBAGENT transcripts, not the parent — a lesson
626
+ * handed to a subagent is cited (or not) in that subagent's own text, and
627
+ * scoring it against the main thread would report 0% by construction. That is
628
+ * also why the Stop path records this face in its own recordCitationSurfaces
629
+ * call: one call carries one `cited` set for every face in it.
630
+ *
631
+ * UNIT — read this before reading the rate it produces. Both sets are unioned
632
+ * across ALL of the session's sidechain files, so the resulting rate is
633
+ * "fraction of injected ids that appear anywhere in any sidechain of this
634
+ * session", NOT per-dispatch adoption. An id handed to agent A and cited by
635
+ * agent B counts as a hit, and an id handed to three agents and cited by one
636
+ * counts as one full hit rather than a third. Observed in real data (review of
637
+ * v3.77.0): 1 of this project's 7 historical hits is exactly that shape. The
638
+ * number is therefore biased HIGH against per-dispatch adoption — which matters
639
+ * because D#152's instruction is to read this rate and then decide whether the
640
+ * face enters the decay denominator (D#164). Per-dispatch attribution would
641
+ * need per-file accounting, deliberately not built yet.
642
+ *
643
+ * @param {string|null|undefined} transcriptPath main-thread transcript (.jsonl)
644
+ * @returns {{injected: Set<number>, cited: Set<number>, files: number}}
645
+ */
646
+ export function collectSubagentSurface(transcriptPath) {
647
+ const injected = new Set();
648
+ const cited = new Set();
649
+ let files = 0;
650
+ for (const p of findSubagentTranscripts(transcriptPath)) {
651
+ files++;
652
+ for (const id of extractInjectedFromSubagentPrompt(p)) injected.add(id);
653
+ for (const id of extractCitationsFromTranscript(p)) cited.add(id);
654
+ }
655
+ return { injected, cited, files };
656
+ }
657
+
568
658
  export function computeThreadCiteRecall(transcriptPath) {
569
659
  const injected = extractAllInjected(transcriptPath);
570
660
  // Subagent files carry NO hook-attachment injection; pre-agent-inject.js injects into
package/mem-cli.mjs CHANGED
@@ -64,7 +64,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
64
64
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
65
65
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
66
66
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
67
- import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation-tracker.mjs';
67
+ import { computeCitationFunnelTrend, computeSurfaceFunnel, DECAY_DENOMINATOR_SURFACES } from './lib/citation-tracker.mjs';
68
68
 
69
69
  // Human labels for citation_surface_log.surface. Padded to a common width so
70
70
  // the citation-stats face table lines up; the enum itself lives in
@@ -74,8 +74,9 @@ const SURFACE_LABELS = {
74
74
  ups: 'UserPromptSubmit ',
75
75
  error_recall: 'error-recall ',
76
76
  fyi: 'FYI (prompt-search)',
77
- task_imperative: 'task-imperative ',
77
+ task_imperative: 'task-imperative ',
78
78
  keyctx: 'Key Context ',
79
+ subagent: 'subagent (dispatch)',
79
80
  };
80
81
  import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
81
82
  import {
@@ -2649,7 +2650,15 @@ function cmdCitationStats(db, args) {
2649
2650
  } else {
2650
2651
  for (const s of surfaceFunnel.surfaces) {
2651
2652
  const pct = (s.rate * 100).toFixed(1) + '%';
2652
- const note = s.surface === 'keyctx' ? ' (promotion-only: never demotes)' : '';
2653
+ // Which faces actually move importance is NOT readable from the rates —
2654
+ // and an annotated keyctx beside a bare task_imperative reads as "that
2655
+ // one does feed decay", which is false. Derived from the exported sets so
2656
+ // the note cannot drift from the behaviour it describes.
2657
+ const note = s.surface === 'keyctx'
2658
+ ? ' (promotion-only: never demotes)'
2659
+ : (DECAY_DENOMINATOR_SURFACES.includes(s.surface)
2660
+ ? ''
2661
+ : ' (metered only: outside the decay denominator)');
2653
2662
  out(` ${SURFACE_LABELS[s.surface] || s.surface} inj ${String(s.injected).padStart(4)} cited ${String(s.cited).padStart(4)} ${pct.padStart(6)} over ${s.sessions} session(s)${note}`);
2654
2663
  }
2655
2664
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.76.2",
3
+ "version": "3.77.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.76.2",
9
+ "version": "3.77.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.76.2",
3
+ "version": "3.77.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
@@ -280,7 +280,7 @@ const CORE_SCHEMA = `
280
280
  -- v45: per-INJECTION-FACE twin of citation_log. One row per
281
281
  -- (project, session, surface); the surface column is one of the
282
282
  -- CITATION_SURFACES enum in lib/citation-tracker.mjs
283
- -- (pretool | ups | error_recall | fyi | task_imperative | keyctx). The column is plain
283
+ -- (pretool | ups | error_recall | fyi | task_imperative | keyctx | subagent). The column is plain
284
284
  -- TEXT with no CHECK: the JS enum is the gate (recordCitationSurfaces drops unknown
285
285
  -- labels), which is why adding a face needs no migration.
286
286
  --