claude-mem-lite 3.11.0 → 3.12.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook.mjs +11 -0
- package/mem-cli.mjs +24 -2
- package/package.json +2 -1
- package/registry-recommend.mjs +304 -0
- package/registry-retriever.mjs +17 -0
- package/scripts/user-prompt-search.js +20 -0
- package/source-files.mjs +3 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.12.1",
|
|
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.
|
|
3
|
+
"version": "3.12.1",
|
|
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
|
|
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,
|
|
@@ -1941,8 +1942,29 @@ import { cmdFtsCheck } from './cli/fts-check.mjs';
|
|
|
1941
1942
|
function cmdRegistry(_memDb, args) {
|
|
1942
1943
|
const { positional, flags } = parseArgs(args);
|
|
1943
1944
|
const action = positional[0];
|
|
1944
|
-
if (!action || !['list', 'stats', 'search', 'import', 'remove', 'reindex'].includes(action)) {
|
|
1945
|
-
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
|
+
}
|
|
1946
1968
|
return;
|
|
1947
1969
|
}
|
|
1948
1970
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.1",
|
|
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,304 @@
|
|
|
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
|
+
// join key MUST be the Skill-tool invocation slug (e.g. 'superpowers:test-driven-development'),
|
|
240
|
+
// NOT the registry short name ('superpowers-tdd') — adoption rows log toolInput.skill (the slug),
|
|
241
|
+
// so logging top.name made in-session matched precision a guaranteed 0 for every namespaced skill.
|
|
242
|
+
skill: top ? (top.invocation_name || top.name) : null,
|
|
243
|
+
relevance: top ? top.relevance : null,
|
|
244
|
+
rel2: candidates[1] ? candidates[1].relevance : null,
|
|
245
|
+
intentTop: top ? intentMatch(promptText, top) : false,
|
|
246
|
+
cooldownTop: top ? cooldownSet.has(String(top.name).toLowerCase()) : false,
|
|
247
|
+
ncand: candidates.length,
|
|
248
|
+
// #8259: UserPromptSubmit injection cite-recall was 25.8% until gated on explicit
|
|
249
|
+
// signal. Record signal-presence so the Phase-2 flip can test whether live injection
|
|
250
|
+
// should gate on it (the decisive lever per that lesson). Shadow does not gate on it.
|
|
251
|
+
hasSignal: opts.hasSignal ?? null,
|
|
252
|
+
});
|
|
253
|
+
// Simulate live cooldown timing so the shadow funnel reflects real injection cadence.
|
|
254
|
+
if (result.verdict === 'PASS') setRecoCooldown(project, result.candidate.name);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** PostToolUse adoption probe — Skill is the only visible adoption signal (mem_use is pre-filtered). */
|
|
259
|
+
export function recordSkillAdoption(toolName, toolInput, project, sessionId = null) {
|
|
260
|
+
if (getRecommendMode() === 'off') return;
|
|
261
|
+
if (toolName !== 'Skill') return;
|
|
262
|
+
const skill = toolInput && typeof toolInput === 'object' ? toolInput.skill : null;
|
|
263
|
+
if (!skill) return;
|
|
264
|
+
// session id (B1): same cross-hook key as the reco row, so adoptions pair to the
|
|
265
|
+
// would-be recommendation that fired earlier in this session (matched precision).
|
|
266
|
+
logShadowAdoption(project, { session: sessionId ?? null, skill: String(skill).toLowerCase() });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Human-readable funnel for `registry recommend-stats`. */
|
|
270
|
+
export function formatFunnel(s) {
|
|
271
|
+
const blocks = Object.entries(s.blockByReason).sort((a, b) => b[1] - a[1])
|
|
272
|
+
.map(([k, v]) => ` ${k}: ${v}`).join('\n') || ' (none)';
|
|
273
|
+
const overlap = Object.keys(s.passSkills).filter(k => s.adoptSkills[k])
|
|
274
|
+
.map(k => `${k}(pass ${s.passSkills[k]}/adopt ${s.adoptSkills[k]})`).join(', ') || '(none)';
|
|
275
|
+
const passRate = s.reco ? (100 * s.pass / s.reco).toFixed(1) : '0.0';
|
|
276
|
+
const lines = [
|
|
277
|
+
'shadow recommendation funnel:',
|
|
278
|
+
` reco=${s.reco} pass=${s.pass} (${passRate}% of reco) adopt=${s.adopt}`,
|
|
279
|
+
` block reasons:\n${blocks}`,
|
|
280
|
+
` PASS∩adopt (coarse precision proxy): ${overlap}`,
|
|
281
|
+
];
|
|
282
|
+
// B2: session-paired matched precision + targeting lift (the flip-decision metrics).
|
|
283
|
+
if (typeof s.sessions === 'number') {
|
|
284
|
+
const mp = s.matched && s.matched.pass
|
|
285
|
+
? `${s.matched.adopt}/${s.matched.pass} (${(100 * s.matched.precision).toFixed(0)}%)` : 'n/a';
|
|
286
|
+
const liftRows = Object.entries(s.lift || {}).filter(([, v]) => Number.isFinite(v.lift))
|
|
287
|
+
.sort((a, b) => b[1].lift - a[1].lift).slice(0, 5)
|
|
288
|
+
.map(([k, v]) => `${k}(lift ${v.lift.toFixed(2)}; ${v.hitSessions}/${v.passSessions} pass→adopt vs base ${(100 * v.baseRate).toFixed(0)}%)`)
|
|
289
|
+
.join(', ') || '(none)';
|
|
290
|
+
lines.push(` sessions=${s.sessions} matched precision (in-session PASS→adopt): ${mp}`);
|
|
291
|
+
lines.push(` targeting lift (>1 = gate beats organic base rate): ${liftRows}`);
|
|
292
|
+
}
|
|
293
|
+
return lines.join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Human-readable threshold sweep for `registry recommend-stats --sweep`. */
|
|
297
|
+
export function formatSweep(grid) {
|
|
298
|
+
const lines = ['gate threshold sweep (floor × margin → pass / matched precision):'];
|
|
299
|
+
for (const g of grid) {
|
|
300
|
+
const prec = Number.isFinite(g.precision) ? `${(100 * g.precision).toFixed(0)}%` : 'n/a';
|
|
301
|
+
lines.push(` floor=${g.floor} margin=${g.margin}: pass=${g.pass} matched=${g.matchAdopt}/${g.matchPass} prec=${prec}`);
|
|
302
|
+
}
|
|
303
|
+
return lines.join('\n');
|
|
304
|
+
}
|
package/registry-retriever.mjs
CHANGED
|
@@ -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/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.
|