claude-mem-lite 3.10.0 → 3.12.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.10.0",
13
+ "version": "3.12.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.10.0",
3
+ "version": "3.12.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
@@ -58,6 +58,7 @@ import {
58
58
  } from './lib/citation-tracker.mjs';
59
59
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
60
60
  import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
61
+ import { recordSkillAdoption } from './registry-recommend.mjs';
61
62
  import { detectMemOverride } from './lib/mem-override.mjs';
62
63
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
63
64
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -257,6 +258,16 @@ async function handlePostToolUse() {
257
258
  if (SKIP_TOOLS.has(tool_name)) return;
258
259
  if (SKIP_PREFIXES.some(p => tool_name.startsWith(p))) return;
259
260
 
261
+ // Shadow skill-adoption telemetry. mem_use is pre-filtered above, so the Skill tool is
262
+ // the only visible adoption signal (v1). Placed before the resp-length gate because a
263
+ // skill load's response shape varies. Never throws.
264
+ if (tool_name === 'Skill') {
265
+ const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
266
+ // hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
267
+ // UserPromptSubmit hook earlier in the same session (matched precision, B1).
268
+ try { recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id); } catch {}
269
+ }
270
+
260
271
  const resp = normalizeToolResponse(tool_response);
261
272
  if (!resp || resp.length < 10) return;
262
273
 
@@ -163,6 +163,12 @@ export async function importJsonl(db, path, { project }) {
163
163
 
164
164
  const pendingToolUse = new Map();
165
165
  let prompts = 0, observations = 0, skipped = 0;
166
+ // Count lines that ARE Claude Code transcript events (user/assistant/tool_result),
167
+ // independent of whether they produced a new row. Lets the caller tell apart a
168
+ // genuine wrong-shape file (export output / garbage → recognized 0) from a valid
169
+ // transcript that was simply already imported (recognized > 0, all deduped) — the
170
+ // "0 imported, N skipped" warning must not cry "wrong shape" at an idempotent re-run.
171
+ let recognized = 0;
166
172
 
167
173
  // Snapshot importToolPair so we can wrap it with a per-run uniqueness
168
174
  // check that hits both in-call and cross-call dedup. (Inline because we
@@ -193,6 +199,8 @@ export async function importJsonl(db, path, { project }) {
193
199
  if (!line.trim()) continue;
194
200
  const ev = parseLine(line);
195
201
  if (!ev) { skipped++; continue; }
202
+ // Transcript-shape signal (incl. embedded + top-level tool_result, #8413).
203
+ if (ev.type === 'user' || ev.type === 'assistant' || ev.type === 'tool_result') recognized++;
196
204
  if (ev.type === 'user') {
197
205
  // Real Claude Code transcripts wrap tool_result inside a user-typed
198
206
  // event's message.content array (alongside the rare text part). The
@@ -256,5 +264,5 @@ export async function importJsonl(db, path, { project }) {
256
264
  tx2();
257
265
  }
258
266
 
259
- return { prompts, observations, skipped, orphans };
267
+ return { prompts, observations, skipped, orphans, recognized };
260
268
  }
@@ -301,7 +301,12 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
301
301
  const stats = db.prepare(`
302
302
  SELECT
303
303
  COUNT(*) as total,
304
+ -- injection_count=0 MUST mirror decayAndMarkIdle's mark-idle guard (#8614):
305
+ -- the scan stat previews what decay will mark idle, and decay protects
306
+ -- injected rows. Omitting it over-counted "stale" by the injected-but-decayed
307
+ -- rows decay never touches (e.g. demote_pinned's output: imp=1 but inj>0).
304
308
  COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
309
+ AND COALESCE(injection_count, 0) = 0
305
310
  AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
306
311
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
307
312
  THEN 1 ELSE 0 END), 0) as broken,
package/mem-cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
+ import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
16
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
17
18
  import {
18
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
@@ -308,18 +309,26 @@ function cmdRecent(db, args) {
308
309
  const { positional, flags } = parseArgs(args);
309
310
  const rawArg = positional[0];
310
311
  const rawLimit = parseInt(rawArg, 10);
312
+ // Single source of the upper bound for BOTH the positional [N] and the --limit
313
+ // flag (help: "alias for [N] (max 1000)"). Pre-fix the positional path skipped
314
+ // this cap, so `recent 999999` issued an uncapped `LIMIT 999999` full-table dump
315
+ // while `recent --limit 999999` correctly rejected → default — exactly the
316
+ // "none capped --limit dumps the whole set" footgun parseIntFlag was extracted
317
+ // to close (lib/cli-flags.mjs). Keep the literal in one place so the two paths
318
+ // can't drift apart again.
319
+ const RECENT_MAX = 1000;
311
320
  // isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
312
321
  // accepted silently; the positional path must reject garbage like the --limit flag does.
313
- const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
322
+ const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
314
323
  if (rawArg !== undefined && !isValid) {
315
- process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
324
+ process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using default 10\n`);
316
325
  }
317
326
  // Positional [N] wins for backward-compat; --limit is sibling-parity alias
318
327
  // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
319
328
  // was silently ignored — surprising users extrapolating from siblings.
320
329
  const limit = isValid
321
330
  ? rawLimit
322
- : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
331
+ : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: RECENT_MAX });
323
332
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
324
333
  const jsonOutput = flags.json === true || flags.json === 'true';
325
334
 
@@ -1768,7 +1777,7 @@ function cmdMaintain(db, args) {
1768
1777
  out(`[mem] Maintenance scan:`);
1769
1778
  out(` Total active: ${stats.total}`);
1770
1779
  out(` Near-duplicate pairs: ${duplicates.length}`);
1771
- out(` Stale (>30d, imp=1, no access): ${stats.stale}`);
1780
+ out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
1772
1781
  out(` Broken (no title/narrative): ${stats.broken}`);
1773
1782
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1774
1783
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
@@ -1933,8 +1942,29 @@ import { cmdFtsCheck } from './cli/fts-check.mjs';
1933
1942
  function cmdRegistry(_memDb, args) {
1934
1943
  const { positional, flags } = parseArgs(args);
1935
1944
  const action = positional[0];
1936
- if (!action || !['list', 'stats', 'search', 'import', 'remove', 'reindex'].includes(action)) {
1937
- fail('[mem] Usage: claude-mem-lite registry <list|stats|search|import|remove|reindex> [--type skill|agent] [--query Q] [--name N] [--resource-type T]');
1945
+ if (!action || !['list', 'stats', 'search', 'import', 'remove', 'reindex', 'recommend-stats'].includes(action)) {
1946
+ fail('[mem] Usage: claude-mem-lite registry <list|stats|search|import|remove|reindex|recommend-stats> [--type skill|agent] [--query Q] [--name N] [--resource-type T]');
1947
+ return;
1948
+ }
1949
+
1950
+ // recommend-stats reads the shadow-recommendation JSONL (filesystem), not the
1951
+ // registry DB — handle it before opening rdb so it works without a registry DB.
1952
+ if (action === 'recommend-stats') {
1953
+ const days = (typeof flags.days === 'string' || typeof flags.days === 'number') && +flags.days > 0 ? Math.floor(+flags.days) : 7;
1954
+ out(formatFunnel(computeFunnel(days)));
1955
+ if (flags.sweep) {
1956
+ // Offline ROC over the shipped gate thresholds (B3). Parse `--floors`/`--margins` as
1957
+ // comma lists of finite numbers; fall back to the spanning defaults on bad/empty input.
1958
+ const parseNums = (v, fallback) => {
1959
+ if (typeof v !== 'string') return fallback;
1960
+ const nums = v.split(',').map(x => Number(x.trim())).filter(Number.isFinite);
1961
+ return nums.length ? nums : fallback;
1962
+ };
1963
+ const floors = parseNums(flags.floors, DEFAULT_SWEEP_FLOORS);
1964
+ const margins = parseNums(flags.margins, DEFAULT_SWEEP_MARGINS);
1965
+ out('');
1966
+ out(formatSweep(computeSweep(days, floors, margins)));
1967
+ }
1938
1968
  return;
1939
1969
  }
1940
1970
 
@@ -2585,7 +2615,7 @@ async function cmdImportJsonl(db, argv) {
2585
2615
  if (files.length === 0) { out('[mem] No .jsonl files found.'); return; }
2586
2616
 
2587
2617
  const { importJsonl } = await import('./lib/import-jsonl.mjs');
2588
- let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, errorCount = 0;
2618
+ let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, totalRecognized = 0, errorCount = 0;
2589
2619
  for (const f of files) {
2590
2620
  // Per-file isolation: one unreadable file (EACCES, EBUSY, mid-batch IO error)
2591
2621
  // shouldn't crash the whole import — readFileSync inside importJsonl would
@@ -2605,18 +2635,29 @@ async function cmdImportJsonl(db, argv) {
2605
2635
  totalObs += r.observations;
2606
2636
  totalSkip += r.skipped;
2607
2637
  totalOrphans += r.orphans || 0;
2638
+ totalRecognized += r.recognized || 0;
2608
2639
  out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2609
2640
  }
2610
2641
  const errorTail = errorCount > 0 ? `, ${errorCount} file(s) errored` : '';
2611
2642
  out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2612
- if (totalPrompts > 0 || totalObs > 0) {
2643
+ if (totalPrompts > 0 || totalObs > 0 || totalOrphans > 0) {
2644
+ // Orphan tool_use events persist as (truncated) observations, so they count as
2645
+ // "something was imported" — otherwise an orphan-only first import would wrongly
2646
+ // fall through to the "already imported" no-op branch below.
2613
2647
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2648
+ } else if (totalRecognized > 0) {
2649
+ // Lines WERE Claude Code transcript events but produced no new rows — the file
2650
+ // was already imported (idempotent re-run) or carried no extractable content.
2651
+ // Distinct from the wrong-shape case below: do NOT cry "wrong shape" at a valid
2652
+ // transcript the user successfully imported earlier (cold-start backfill re-runs
2653
+ // hit this on every already-ingested file).
2654
+ out(`[mem] Nothing new: ${totalRecognized} transcript event(s) already imported (re-running import-jsonl on the same transcript is a safe no-op).`);
2614
2655
  } else if (totalSkip > 0 && errorCount === 0) {
2615
- // Nothing imported but every line was skipped — almost always the wrong file
2616
- // format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
2617
- // which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
2618
- // it at the wrong file looked like success. Make the no-op explicit (stdout, like
2619
- // the summary lines above).
2656
+ // No transcript event recognized at all — almost always the wrong file format
2657
+ // (import-jsonl ingests Claude Code transcript JSONL, not `export` output, which
2658
+ // is observation-shaped). Pre-fix this exited 0 with no signal, so pointing it at
2659
+ // the wrong file looked like success. Make the no-op explicit (stdout, like the
2660
+ // summary lines above).
2620
2661
  out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
2621
2662
  }
2622
2663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.10.0",
3
+ "version": "3.12.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",
@@ -95,6 +95,7 @@
95
95
  "server/fts-check.mjs",
96
96
  "registry.mjs",
97
97
  "registry-retriever.mjs",
98
+ "registry-recommend.mjs",
98
99
  "registry-indexer.mjs",
99
100
  "registry-scanner.mjs",
100
101
  "registry-github.mjs",
@@ -0,0 +1,301 @@
1
+ // Intent-based skill recommendation — Phase 1 (shadow).
2
+ // See docs/superpowers/specs/2026-06-23-skill-recommendation-loop-design.md
3
+ //
4
+ // Phase-1 invariant: shadow AND live only LOG. Neither emits to stdout nor writes
5
+ // invocations/recommend_count. Live injection is Phase 2. `off` skips all work.
6
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+ import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
10
+
11
+ const VALID_MODES = new Set(['shadow', 'live', 'off']);
12
+
13
+ /** Recommendation mode from env; default 'shadow', unknown → 'shadow'. */
14
+ export function getRecommendMode() {
15
+ const raw = (process.env.CLAUDE_MEM_RECOMMEND_MODE || 'shadow').toLowerCase().trim();
16
+ return VALID_MODES.has(raw) ? raw : 'shadow';
17
+ }
18
+
19
+ // Provisional gate thresholds — calibrated from shadow data before Phase 2 (spec §8).
20
+ // relevance is raw bm25 (negative; more negative = better). Candidate must clear |floor|.
21
+ export const RECO_BM25_FLOOR = -1.5; // require row.relevance <= -1.5
22
+ export const RECO_MARGIN = 0.5; // require candidates[1].relevance - candidates[0].relevance >= 0.5
23
+ const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS (internal)
24
+
25
+ // Lazy runtime-path resolution: read CLAUDE_MEM_DIR at call time (mirrors schema.mjs:13
26
+ // DB_DIR formula) so tests sandbox via env without ESM-cache gymnastics, and prod reads
27
+ // the same dir as the rest of the app.
28
+ function recoRuntimeDir() {
29
+ return join(process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite'), 'runtime');
30
+ }
31
+
32
+ const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
33
+
34
+ /** Installed skills (quality_tier='installed') matching the prompt, best-first. */
35
+ export function fetchInstalledSkillCandidates(rdb, promptText, limit = 10) {
36
+ if (!rdb || !promptText) return [];
37
+ let rows;
38
+ try { rows = searchResources(rdb, promptText, { type: 'skill', limit }); }
39
+ catch { return []; }
40
+ return rows.filter(r => r.quality_tier === 'installed');
41
+ }
42
+
43
+ /**
44
+ * True when an intent tag matches a prompt token. Tags of length >= 3 match as a token
45
+ * PREFIX (plural/inflection tolerant: "test" → "tests"/"testing"; rejects mid-word hits
46
+ * like "latest"); shorter tags ("qa","go","db") require an exact token to avoid noise.
47
+ */
48
+ export function intentMatch(promptText, candidate) {
49
+ const tags = String(candidate.intent_tags || '').toLowerCase().split(/[,\s]+/).filter(Boolean);
50
+ if (tags.length === 0) return false;
51
+ const tokens = String(promptText).toLowerCase().split(TOKEN_SPLIT).filter(Boolean);
52
+ // CJK bridge: intent_tags are English, but Chinese has no word boundaries, so a pure-中文
53
+ // prompt ("写测试") tokenizes to one CJK run that never prefix-matches "test". Inject the
54
+ // English equivalents of any CJK_INTENT_MAP phrase present so 中文 prompts can clear gate 3.
55
+ for (const en of cjkIntentTokens(promptText)) tokens.push(en);
56
+ return tags.some(tag => tokens.some(tok => (tag.length >= 3 ? tok.startsWith(tag) : tok === tag)));
57
+ }
58
+
59
+ /**
60
+ * 4-gate precision filter. relevance is raw bm25 (negative; more negative = better).
61
+ * Gates in order: absolute floor → top1/top2 margin → intent token match → session cooldown.
62
+ */
63
+ export function applyGate(candidates, promptText, cooldownSet) {
64
+ if (!candidates || candidates.length === 0) return { verdict: 'BLOCK', reason: 'no_candidate', candidate: null };
65
+ const top = candidates[0];
66
+ if (!(top.relevance <= RECO_BM25_FLOOR)) return { verdict: 'BLOCK', reason: 'below_floor', candidate: top };
67
+ if (candidates.length >= 2) {
68
+ const margin = candidates[1].relevance - top.relevance; // positive when top is clearly better
69
+ if (!(margin >= RECO_MARGIN)) return { verdict: 'BLOCK', reason: 'low_margin', candidate: top };
70
+ }
71
+ if (!intentMatch(promptText, top)) return { verdict: 'BLOCK', reason: 'intent_mismatch', candidate: top };
72
+ if (cooldownSet && cooldownSet.has(String(top.name).toLowerCase())) return { verdict: 'BLOCK', reason: 'cooldown', candidate: top };
73
+ return { verdict: 'PASS', reason: 'pass', candidate: top };
74
+ }
75
+
76
+ function recoCooldownFile(project) { return join(recoRuntimeDir(), `.skill-reco-cooldown-${project}`); }
77
+
78
+ /** Read live (non-expired) cooldown entries for a project: {nameLower: epochMs}. */
79
+ export function getRecoCooldown(project) {
80
+ try {
81
+ const data = JSON.parse(readFileSync(recoCooldownFile(project), 'utf8'));
82
+ const now = Date.now(); const cleaned = {};
83
+ for (const [k, v] of Object.entries(data)) if (now - v < RECO_COOLDOWN_MS) cleaned[k] = v;
84
+ return cleaned;
85
+ } catch { return {}; }
86
+ }
87
+
88
+ /** Stamp a skill name into the project's cooldown (atomic tmp+rename, mirrors T4). */
89
+ export function setRecoCooldown(project, name) {
90
+ try {
91
+ const dir = recoRuntimeDir();
92
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
93
+ const data = getRecoCooldown(project);
94
+ data[String(name).toLowerCase()] = Date.now();
95
+ const tmp = recoCooldownFile(project) + `.tmp-${process.pid}`;
96
+ writeFileSync(tmp, JSON.stringify(data));
97
+ renameSync(tmp, recoCooldownFile(project));
98
+ } catch { /* silent — cooldown best-effort */ }
99
+ }
100
+
101
+ function today() { return new Date().toISOString().slice(0, 10); }
102
+ function shadowDir() { return join(recoRuntimeDir(), 'recommendations'); }
103
+
104
+ function appendShadow(row) {
105
+ try {
106
+ const dir = shadowDir();
107
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
108
+ appendFileSync(join(dir, `${today()}.jsonl`), JSON.stringify(row) + '\n', { mode: 0o600 });
109
+ } catch { /* shadow sink must never crash the hook */ }
110
+ }
111
+
112
+ export function logShadowReco(project, rec) { appendShadow({ ts: new Date().toISOString(), kind: 'reco', project, ...rec }); }
113
+ export function logShadowAdoption(project, rec) { appendShadow({ ts: new Date().toISOString(), kind: 'adopt', project, ...rec }); }
114
+
115
+ /** Yield parsed shadow rows from the last `days` daily shards. */
116
+ export function* readShadowLog(days = 7) {
117
+ for (let i = 0; i < days; i++) {
118
+ const d = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10);
119
+ let raw;
120
+ try { raw = readFileSync(join(shadowDir(), `${d}.jsonl`), 'utf8'); } catch { continue; }
121
+ for (const line of raw.split('\n')) { if (!line) continue; try { yield JSON.parse(line); } catch { /* skip malformed */ } }
122
+ }
123
+ }
124
+
125
+ const lc = (s) => String(s ?? '').toLowerCase();
126
+
127
+ /**
128
+ * Aggregate the would-be funnel for the flip decision (spec §8).
129
+ *
130
+ * Coarse global counts (reco/pass/blockByReason/adopt/passSkills/adoptSkills) are kept for
131
+ * back-compat. The flip-decision metrics live under `matched`/`lift`/`sessions`:
132
+ * - matched: in-session PASS→adopt pairing (true matched precision, not a global name join).
133
+ * - lift[skill]: P(adopt | gate PASSed it this session) / base-rate(adopt). >1 means the gate
134
+ * beats the skill's organic base rate — the only popularity-robust evidence the gate has
135
+ * targeting signal. Null-session rows can't be paired, so they feed only the coarse counts.
136
+ */
137
+ export function computeFunnel(days = 7) {
138
+ const stats = { reco: 0, pass: 0, blockByReason: {}, adopt: 0, passSkills: {}, adoptSkills: {} };
139
+ const bump = (obj, k) => { if (k) obj[k] = (obj[k] || 0) + 1; };
140
+ const bySession = new Map();
141
+ const sess = (id) => { let e = bySession.get(id); if (!e) { e = { pass: new Set(), adopt: new Set() }; bySession.set(id, e); } return e; };
142
+ for (const row of readShadowLog(days)) {
143
+ if (row.kind === 'reco') {
144
+ stats.reco++;
145
+ if (row.verdict === 'PASS') { stats.pass++; bump(stats.passSkills, row.skill); if (row.session) sess(row.session).pass.add(lc(row.skill)); }
146
+ else bump(stats.blockByReason, row.reason);
147
+ } else if (row.kind === 'adopt') {
148
+ stats.adopt++; bump(stats.adoptSkills, row.skill);
149
+ if (row.session) sess(row.session).adopt.add(lc(row.skill));
150
+ }
151
+ }
152
+ stats.sessions = bySession.size;
153
+ let mPass = 0, mAdopt = 0;
154
+ const passSessions = {}, hitSessions = {}, baseSessions = {};
155
+ for (const { pass, adopt } of bySession.values()) {
156
+ for (const sk of pass) { mPass++; passSessions[sk] = (passSessions[sk] || 0) + 1; if (adopt.has(sk)) { mAdopt++; hitSessions[sk] = (hitSessions[sk] || 0) + 1; } }
157
+ for (const sk of adopt) baseSessions[sk] = (baseSessions[sk] || 0) + 1;
158
+ }
159
+ stats.matched = { pass: mPass, adopt: mAdopt, precision: mPass ? mAdopt / mPass : null };
160
+ stats.lift = {};
161
+ for (const sk of Object.keys(passSessions)) {
162
+ const ps = passSessions[sk], hs = hitSessions[sk] || 0;
163
+ const baseRate = stats.sessions ? (baseSessions[sk] || 0) / stats.sessions : 0;
164
+ const adoptGivenPass = ps ? hs / ps : 0;
165
+ stats.lift[sk] = { passSessions: ps, hitSessions: hs, adoptGivenPass, baseRate, lift: baseRate > 0 ? adoptGivenPass / baseRate : null };
166
+ }
167
+ return stats;
168
+ }
169
+
170
+ // Default sweep grid (B3). Raw BM25 magnitudes for real matches run ≈ -10..-15, so the
171
+ // shipped floor (-1.5) is far more permissive than it looks; the grid spans that real range
172
+ // so the ROC curve actually moves. Override via `recommend-stats --sweep --floors a,b --margins x,y`.
173
+ export const DEFAULT_SWEEP_FLOORS = [-1.5, -5, -8, -10, -12];
174
+ export const DEFAULT_SWEEP_MARGINS = [0, 0.5, 1, 2, 4];
175
+
176
+ /**
177
+ * Recompute the gate verdict for a logged reco row at a hypothetical (floor, margin),
178
+ * from its eager replay vector (relevance/rel2/intentTop/cooldownTop). Pure — no retrieval.
179
+ * intent/cooldown are fixed pass/block bits; only floor and margin are swept.
180
+ */
181
+ export function replayGate(row, floor, margin) {
182
+ const r1 = row.relevance;
183
+ // null/undefined relevance coerces to 0/NaN, so `r1 <= floor` is false → BLOCK (no null check).
184
+ if (!(r1 <= floor)) return 'BLOCK';
185
+ if (Number.isFinite(row.rel2) && !((row.rel2 - r1) >= margin)) return 'BLOCK';
186
+ if (!row.intentTop) return 'BLOCK';
187
+ if (row.cooldownTop) return 'BLOCK';
188
+ return 'PASS';
189
+ }
190
+
191
+ /**
192
+ * Offline ROC sweep (B3): replay every reco row at each (floor × margin) and join the would-be
193
+ * PASSes with in-session adoptions for matched precision. Turns the flip decision from one
194
+ * underpowered point estimate into a precision/recall curve over collected shadow data (spec §8).
195
+ */
196
+ export function computeSweep(days = 7, floors = DEFAULT_SWEEP_FLOORS, margins = DEFAULT_SWEEP_MARGINS) {
197
+ const recos = [];
198
+ const adoptBySession = new Map();
199
+ for (const row of readShadowLog(days)) {
200
+ if (row.kind === 'reco') recos.push(row);
201
+ else if (row.kind === 'adopt' && row.session) {
202
+ if (!adoptBySession.has(row.session)) adoptBySession.set(row.session, new Set());
203
+ adoptBySession.get(row.session).add(lc(row.skill));
204
+ }
205
+ }
206
+ const grid = [];
207
+ for (const floor of floors) for (const margin of margins) {
208
+ let pass = 0, matchPass = 0, matchAdopt = 0;
209
+ for (const r of recos) {
210
+ if (replayGate(r, floor, margin) !== 'PASS') continue;
211
+ pass++;
212
+ if (r.session) { matchPass++; if (adoptBySession.get(r.session)?.has(lc(r.skill))) matchAdopt++; }
213
+ }
214
+ grid.push({ floor, margin, pass, matchPass, matchAdopt, precision: matchPass ? matchAdopt / matchPass : null });
215
+ }
216
+ return grid;
217
+ }
218
+
219
+ /**
220
+ * Phase-1 shadow orchestrator: retrieve → gate → log → cooldown on PASS.
221
+ * Emits NOTHING and writes NO live telemetry. `off` → no-op. Returns the verdict.
222
+ */
223
+ export function recommendSkill(rdb, promptText, project, opts = {}) {
224
+ const mode = getRecommendMode();
225
+ if (mode === 'off') return { verdict: 'OFF', reason: 'off', candidate: null };
226
+ let candidates = [];
227
+ try { candidates = fetchInstalledSkillCandidates(rdb, promptText); } catch { /* ignore */ }
228
+ const cooldownSet = new Set(Object.keys(getRecoCooldown(project)));
229
+ const result = applyGate(candidates, promptText, cooldownSet);
230
+ // Eager replay vector (B3): applyGate short-circuits, so a row that BLOCKed at below_floor
231
+ // never evaluated intent/cooldown. Compute the gate's raw inputs unconditionally here so an
232
+ // offline sweep can recompute the verdict at any (floor, margin) without re-running retrieval.
233
+ const top = candidates[0] || null;
234
+ logShadowReco(project, {
235
+ // session id (B1): the cross-hook key that lets PostToolUse adoptions be paired with
236
+ // this reco in the SAME session — without it, precision is only a global name-set join.
237
+ session: opts.sessionId ?? null,
238
+ mode, verdict: result.verdict, reason: result.reason,
239
+ skill: top ? top.name : null,
240
+ relevance: top ? top.relevance : null,
241
+ rel2: candidates[1] ? candidates[1].relevance : null,
242
+ intentTop: top ? intentMatch(promptText, top) : false,
243
+ cooldownTop: top ? cooldownSet.has(String(top.name).toLowerCase()) : false,
244
+ ncand: candidates.length,
245
+ // #8259: UserPromptSubmit injection cite-recall was 25.8% until gated on explicit
246
+ // signal. Record signal-presence so the Phase-2 flip can test whether live injection
247
+ // should gate on it (the decisive lever per that lesson). Shadow does not gate on it.
248
+ hasSignal: opts.hasSignal ?? null,
249
+ });
250
+ // Simulate live cooldown timing so the shadow funnel reflects real injection cadence.
251
+ if (result.verdict === 'PASS') setRecoCooldown(project, result.candidate.name);
252
+ return result;
253
+ }
254
+
255
+ /** PostToolUse adoption probe — Skill is the only visible adoption signal (mem_use is pre-filtered). */
256
+ export function recordSkillAdoption(toolName, toolInput, project, sessionId = null) {
257
+ if (getRecommendMode() === 'off') return;
258
+ if (toolName !== 'Skill') return;
259
+ const skill = toolInput && typeof toolInput === 'object' ? toolInput.skill : null;
260
+ if (!skill) return;
261
+ // session id (B1): same cross-hook key as the reco row, so adoptions pair to the
262
+ // would-be recommendation that fired earlier in this session (matched precision).
263
+ logShadowAdoption(project, { session: sessionId ?? null, skill: String(skill).toLowerCase() });
264
+ }
265
+
266
+ /** Human-readable funnel for `registry recommend-stats`. */
267
+ export function formatFunnel(s) {
268
+ const blocks = Object.entries(s.blockByReason).sort((a, b) => b[1] - a[1])
269
+ .map(([k, v]) => ` ${k}: ${v}`).join('\n') || ' (none)';
270
+ const overlap = Object.keys(s.passSkills).filter(k => s.adoptSkills[k])
271
+ .map(k => `${k}(pass ${s.passSkills[k]}/adopt ${s.adoptSkills[k]})`).join(', ') || '(none)';
272
+ const passRate = s.reco ? (100 * s.pass / s.reco).toFixed(1) : '0.0';
273
+ const lines = [
274
+ 'shadow recommendation funnel:',
275
+ ` reco=${s.reco} pass=${s.pass} (${passRate}% of reco) adopt=${s.adopt}`,
276
+ ` block reasons:\n${blocks}`,
277
+ ` PASS∩adopt (coarse precision proxy): ${overlap}`,
278
+ ];
279
+ // B2: session-paired matched precision + targeting lift (the flip-decision metrics).
280
+ if (typeof s.sessions === 'number') {
281
+ const mp = s.matched && s.matched.pass
282
+ ? `${s.matched.adopt}/${s.matched.pass} (${(100 * s.matched.precision).toFixed(0)}%)` : 'n/a';
283
+ const liftRows = Object.entries(s.lift || {}).filter(([, v]) => Number.isFinite(v.lift))
284
+ .sort((a, b) => b[1].lift - a[1].lift).slice(0, 5)
285
+ .map(([k, v]) => `${k}(lift ${v.lift.toFixed(2)}; ${v.hitSessions}/${v.passSessions} pass→adopt vs base ${(100 * v.baseRate).toFixed(0)}%)`)
286
+ .join(', ') || '(none)';
287
+ lines.push(` sessions=${s.sessions} matched precision (in-session PASS→adopt): ${mp}`);
288
+ lines.push(` targeting lift (>1 = gate beats organic base rate): ${liftRows}`);
289
+ }
290
+ return lines.join('\n');
291
+ }
292
+
293
+ /** Human-readable threshold sweep for `registry recommend-stats --sweep`. */
294
+ export function formatSweep(grid) {
295
+ const lines = ['gate threshold sweep (floor × margin → pass / matched precision):'];
296
+ for (const g of grid) {
297
+ const prec = Number.isFinite(g.precision) ? `${(100 * g.precision).toFixed(0)}%` : 'n/a';
298
+ lines.push(` floor=${g.floor} margin=${g.margin}: pass=${g.pass} matched=${g.matchAdopt}/${g.matchPass} prec=${prec}`);
299
+ }
300
+ return lines.join('\n');
301
+ }
@@ -85,6 +85,23 @@ function extractCJKTokens(text) {
85
85
  return found;
86
86
  }
87
87
 
88
+ /**
89
+ * English intent equivalents of any CJK_INTENT_MAP phrase present in `text` (deduped).
90
+ * Shared primitive so the skill-recommendation gate's intent match (registry-recommend.mjs)
91
+ * can bridge pure-中文 prompts to English intent_tags — Chinese has no word boundaries, so a
92
+ * phrase like "写测试" never token-matches the English tag "test" without this. Latin in keys
93
+ * ("修bug") is lowercased on the haystack so case can't miss; CJK is case-invariant.
94
+ */
95
+ export function cjkIntentTokens(text) {
96
+ const s = String(text || '').toLowerCase();
97
+ const out = [];
98
+ const seen = new Set();
99
+ for (const [key, en] of Object.entries(CJK_INTENT_MAP)) {
100
+ if (s.includes(key) && !seen.has(en)) { seen.add(en); out.push(en); }
101
+ }
102
+ return out;
103
+ }
104
+
88
105
  // ─── Query Building ──────────────────────────────────────────────────────────
89
106
 
90
107
  /**
@@ -11,6 +11,7 @@ import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
11
11
  import { join, sep } from 'path';
12
12
  import Database from 'better-sqlite3';
13
13
  import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
14
+ import { recommendSkill } from '../registry-recommend.mjs';
14
15
 
15
16
  // ─── Constants ──────────────────────────────────────────────────────────────
16
17
 
@@ -697,6 +698,25 @@ async function main() {
697
698
  }
698
699
  }
699
700
  } catch { /* silent — never block on registry failure */ }
701
+
702
+ // ─── L2: Intent-based skill recommendation (shadow-first, v3.12) ─────
703
+ // Distinct from L1 (explicit-name pointer): fires on intent even when the
704
+ // user did not name a skill. Phase 1 = shadow only (logs, never emits).
705
+ // Reuses a readonly registry DB; cooldown/shadow writes go to the FS.
706
+ try {
707
+ if (existsSync(REGISTRY_DB_PATH)) {
708
+ // #8259: explicit-signal presence is the decisive lever for UPS injection
709
+ // quality (UPS cite-recall was 25.8% until gated on it). Logged in shadow so
710
+ // Phase 2 can decide whether live injection gates on it. Shadow measures broadly.
711
+ const hasSignal = !!(extractErrorSignature(promptText) || extractFiles(promptText).length > 0 || detectIntent(promptText));
712
+ const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
713
+ rdb.pragma('busy_timeout = 500');
714
+ // CC session_id (hook stdin) is the cross-hook pairing key: PostToolUse adoptions
715
+ // in this same session join back to this reco for matched precision (B1).
716
+ try { recommendSkill(rdb, promptText, inferProject(), { hasSignal, sessionId: hookData.session_id }); }
717
+ finally { rdb.close(); }
718
+ }
719
+ } catch { /* silent — never block on recommendation failure */ }
700
720
  } catch {
701
721
  // Hooks must never break Claude Code — swallow all errors
702
722
  } finally {
package/secret-scrub.mjs CHANGED
@@ -11,12 +11,14 @@ export const SECRET_PATTERNS = [
11
11
  // and short values (<6 chars) that are typically variable names not secrets.
12
12
  //
13
13
  // Split into two patterns so prose mentions don't get scrubbed:
14
- // 1. Bare credential nouns (password|passwd|token|bearer) commonly appear in
15
- // English prose — "Marker token: xyzpdq", "the bearer: alice". We require
16
- // the keyword NOT to be preceded by an English-word + horizontal-space
17
- // (the prose mention shape). Code/config has the keyword at start-of-line,
18
- // after a separator, or in object-literal context none of which match
19
- // "letter-then-space" preceding the keyword.
14
+ // 1. Bare credential nouns (password|passwd|token|bearer|secret) commonly appear
15
+ // in English prose — "Marker token: xyzpdq", "the bearer: alice". The prose
16
+ // mention shape is the `:` form, so the prose lookbehind (NOT preceded by
17
+ // English-word + horizontal-space) guards ONLY the `:` separator. An `=` is
18
+ // config-assignment syntax, never prose, so `<word> password=<secret>` ALWAYS
19
+ // scrubs without this split that leaked (the lookbehind skipped any noun
20
+ // after "word ", regardless of separator). No pinned prose case uses `=` (all
21
+ // are `:`), so the `=` arm is leak-closing with no FP shift on the protected set.
20
22
  // 2. Structured keys (api_key, auth_token, …) keep the original behavior —
21
23
  // a separator/compound key is unambiguous config syntax even when
22
24
  // preceded by prose ("see auth_token: shhhhhh").
@@ -26,7 +28,10 @@ export const SECRET_PATTERNS = [
26
28
  // keyword. Allowing a leading `_` catches those while the prose lookbehind still
27
29
  // excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
28
30
  // value is covered (the hex-only assignment pattern below misses non-hex values).
29
- [/((?<![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***'],
31
+ // 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
32
+ [/((?:\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***'],
33
+ // 1b. `:` separator → keep the prose lookbehind ("the token: alice" is prose):
34
+ [/((?<![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***'],
30
35
  // access_token / refresh_token are the canonical OAuth2 field names — they were
31
36
  // missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
32
37
  // underscore-prefix reason.
@@ -47,8 +52,11 @@ export const SECRET_PATTERNS = [
47
52
  // object-literal / YAML / quoted-.env shapes. Split into the SAME two patterns as the
48
53
  // unquoted KV pairs above so prose survives — a quoted value does not turn prose into
49
54
  // config (`the token: "x"` is still prose, must NOT scrub; #8283 / utils.test.mjs:1090).
50
- // (a) bare credential nouns keep the prose lookbehind:
51
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
55
+ // (a) bare credential nouns: `=` always scrubs; `:` keeps the prose lookbehind
56
+ // (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
57
+ // into config, but `<word> password="x"` is still a leak):
58
+ [/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
59
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
52
60
  // (b) structured keys + named env vars are unambiguous config even after a word
53
61
  // (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
54
62
  [/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
package/server.mjs CHANGED
@@ -997,7 +997,7 @@ server.registerTool(
997
997
  `Memory maintenance scan:`,
998
998
  ` Total active observations: ${stats.total}`,
999
999
  ` Near-duplicate pairs: ${duplicates.length}`,
1000
- ` Stale (>30d, imp=1, no access): ${stats.stale}`,
1000
+ ` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
1001
1001
  ` Broken (no title/narrative): ${stats.broken}`,
1002
1002
  ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1003
1003
  ` Pending purge (idle-marked): ${stats.pendingPurge}`,
package/source-files.mjs CHANGED
@@ -15,6 +15,9 @@ export const SOURCE_FILES = [
15
15
  'package.json', 'package-lock.json', 'skill.md',
16
16
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
17
17
  'registry-retriever.mjs', 'resource-discovery.mjs',
18
+ // registry-recommend.mjs: statically imported by hook.mjs (PostToolUse adoption probe)
19
+ // and scripts/user-prompt-search.js (UserPromptSubmit shadow recommendation).
20
+ 'registry-recommend.mjs',
18
21
  // registry-enricher/-github/-importer are dynamically imported by server.mjs
19
22
  // (mem_registry tool) and mem-cli.mjs (registry CLI subcommands). Missing
20
23
  // them from SOURCE_FILES silently broke those code paths prior to this fix.