claude-mem-lite 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +19 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +486 -0
- package/README.zh-CN.md +486 -0
- package/commands/mem.md +34 -0
- package/dispatch-feedback.mjs +139 -0
- package/dispatch-inject.mjs +147 -0
- package/dispatch.mjs +692 -0
- package/haiku-client.mjs +165 -0
- package/hook-context.mjs +176 -0
- package/hook-episode.mjs +222 -0
- package/hook-llm.mjs +358 -0
- package/hook-semaphore.mjs +84 -0
- package/hook-shared.mjs +174 -0
- package/hook.mjs +849 -0
- package/hooks/hooks.json +77 -0
- package/install.mjs +948 -0
- package/package.json +69 -0
- package/registry/preinstalled.json +279 -0
- package/registry-indexer.mjs +172 -0
- package/registry-retriever.mjs +372 -0
- package/registry-scanner.mjs +253 -0
- package/registry.mjs +350 -0
- package/resource-discovery.mjs +189 -0
- package/schema.mjs +267 -0
- package/scripts/post-tool-use.sh +60 -0
- package/scripts/setup.sh +83 -0
- package/server-internals.mjs +195 -0
- package/server.mjs +938 -0
- package/skill.md +35 -0
- package/tool-schemas.mjs +56 -0
- package/utils.mjs +594 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// claude-mem-lite: Resource retriever — FTS5 search + composite scoring
|
|
2
|
+
// Tier 2 of the 3-tier dispatch intelligence architecture
|
|
3
|
+
|
|
4
|
+
import { debugCatch } from './utils.mjs';
|
|
5
|
+
|
|
6
|
+
// ─── Domain Synonyms ─────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const DISPATCH_SYNONYMS = {
|
|
9
|
+
// English intent synonyms
|
|
10
|
+
'clean': ['refactor', 'lint', 'format', 'organize', 'tidy', 'simplify', 'restructure', 'rewrite', 'smell', 'debt'],
|
|
11
|
+
'test': ['testing', 'unittest', 'e2e', 'coverage', 'tdd', 'qa', 'spec', 'jest', 'vitest', 'pytest', 'mocha', 'cypress', 'playwright'],
|
|
12
|
+
'fix': ['debug', 'bugfix', 'troubleshoot', 'diagnose', 'repair', 'error', 'crash', 'broken', 'issue', 'problem'],
|
|
13
|
+
'fast': ['performance', 'optimize', 'profile', 'benchmark', 'speed', 'latency', 'bottleneck', 'slow', 'cache'],
|
|
14
|
+
'deploy': ['release', 'publish', 'ci', 'cd', 'ship', 'rollout', 'staging', 'production'],
|
|
15
|
+
'commit': ['git', 'push', 'merge', 'pr', 'branch', 'version', 'rebase', 'stash', 'tag'],
|
|
16
|
+
'secure': ['security', 'vulnerability', 'audit', 'secrets', 'auth', 'xss', 'csrf', 'injection', 'encrypt', 'ssl', 'tls', 'cors', 'oauth', 'jwt', 'cve'],
|
|
17
|
+
'review': ['code-review', 'pr-review', 'quality', 'inspect', 'check', 'audit'],
|
|
18
|
+
'doc': ['documentation', 'readme', 'docs', 'comment', 'jsdoc', 'typedoc', 'changelog', 'wiki', 'guide'],
|
|
19
|
+
'design': ['ui', 'ux', 'frontend', 'layout', 'css', 'component', 'tailwind', 'responsive', 'theme'],
|
|
20
|
+
'infra': ['infrastructure', 'devops', 'docker', 'kubernetes', 'terraform', 'ansible', 'helm', 'aws', 'gcp', 'azure', 'nginx', 'pipeline', 'cloud'],
|
|
21
|
+
'db': ['database', 'sql', 'postgres', 'mysql', 'mongodb', 'schema', 'migration', 'orm', 'prisma', 'redis', 'sqlite', 'drizzle', 'sequelize'],
|
|
22
|
+
'api': ['endpoint', 'rest', 'graphql', 'route', 'backend', 'grpc', 'websocket', 'middleware', 'swagger', 'openapi'],
|
|
23
|
+
'plan': ['planning', 'architecture', 'spec', 'blueprint', 'rfc', 'proposal', 'roadmap'],
|
|
24
|
+
'build': ['compile', 'bundle', 'webpack', 'vite', 'typescript', 'tsc', 'esbuild', 'rollup', 'parcel', 'babel', 'swc', 'transpile'],
|
|
25
|
+
'lint': ['eslint', 'prettier', 'biome', 'stylelint', 'format', 'style'],
|
|
26
|
+
// Chinese intent mappings
|
|
27
|
+
'清理': ['refactor', 'clean', 'lint', 'format', 'simplify'],
|
|
28
|
+
'测试': ['test', 'testing', 'tdd', 'qa', 'spec', 'jest', 'vitest', 'pytest'],
|
|
29
|
+
'提交': ['commit', 'git', 'push', 'pr'],
|
|
30
|
+
'部署': ['deploy', 'release', 'ci', 'ship'],
|
|
31
|
+
'优化': ['optimize', 'performance', 'fast', 'speed', 'cache'],
|
|
32
|
+
'安全': ['security', 'audit', 'vulnerability', 'auth', 'xss', 'csrf'],
|
|
33
|
+
'审查': ['review', 'code-review', 'pr-review', 'quality'],
|
|
34
|
+
'修复': ['fix', 'debug', 'bugfix', 'repair', 'error', 'crash'],
|
|
35
|
+
'文档': ['documentation', 'readme', 'docs'],
|
|
36
|
+
'设计': ['design', 'ui', 'ux', 'frontend', 'layout', 'component'],
|
|
37
|
+
'构建': ['build', 'compile', 'bundle', 'webpack', 'vite'],
|
|
38
|
+
'重构': ['refactor', 'restructure', 'simplify', 'clean'],
|
|
39
|
+
'数据库': ['database', 'sql', 'schema', 'migration', 'orm'],
|
|
40
|
+
'接口': ['api', 'endpoint', 'rest', 'route', 'backend'],
|
|
41
|
+
'规划': ['planning', 'architecture', 'spec', 'blueprint'],
|
|
42
|
+
'格式化': ['lint', 'format', 'eslint', 'prettier', 'style'],
|
|
43
|
+
'编译': ['compile', 'build', 'bundle', 'transpile'],
|
|
44
|
+
'打包': ['bundle', 'build', 'webpack', 'vite'],
|
|
45
|
+
'容器': ['docker', 'container', 'kubernetes', 'infrastructure'],
|
|
46
|
+
'运维': ['devops', 'infrastructure', 'deploy', 'docker'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ─── CJK Tokenization ───────────────────────────────────────────────────────
|
|
50
|
+
// Chinese text has no word boundaries (no spaces between words).
|
|
51
|
+
// Two-layer extraction:
|
|
52
|
+
// 1. DISPATCH_SYNONYMS CJK keys → synonym-expanded via expandToken()
|
|
53
|
+
// 2. CJK_INTENT_MAP → inject English equivalents for FTS5 matching
|
|
54
|
+
|
|
55
|
+
const CJK_INTENT_MAP = {
|
|
56
|
+
// test
|
|
57
|
+
'测试': 'test', '写测试': 'test', '单测': 'test', '单元测试': 'test',
|
|
58
|
+
'用例': 'test', '覆盖率': 'coverage',
|
|
59
|
+
// fix/debug — synced with extractIntent CJK patterns
|
|
60
|
+
'修复': 'fix', '调试': 'debug', '排错': 'debug', '报错': 'error',
|
|
61
|
+
'出错': 'error', '修bug': 'fix', '改bug': 'fix', '找bug': 'debug',
|
|
62
|
+
'有bug': 'fix', '有问题': 'fix', '不工作': 'fix', '跑不起来': 'fix',
|
|
63
|
+
'不能用': 'fix', '挂了': 'crash', '崩溃': 'crash',
|
|
64
|
+
// commit
|
|
65
|
+
'提交': 'commit', '推送': 'push', '上传': 'push',
|
|
66
|
+
// deploy
|
|
67
|
+
'部署': 'deploy', '上线': 'deploy', '发布': 'release', '回滚': 'rollback',
|
|
68
|
+
// review
|
|
69
|
+
'审查': 'review', '审核': 'review', '评审': 'review', '代码审查': 'review',
|
|
70
|
+
'代码审核': 'review', '看看代码': 'review',
|
|
71
|
+
// clean
|
|
72
|
+
'重构': 'refactor', '清理': 'clean', '整理': 'clean', '简化': 'simplify',
|
|
73
|
+
'太烂': 'refactor', '乱七八糟': 'refactor', '看不懂': 'refactor',
|
|
74
|
+
// performance
|
|
75
|
+
'优化': 'optimize', '性能': 'performance', '卡顿': 'performance', '太慢': 'performance',
|
|
76
|
+
'耗时': 'performance', '慢死了': 'performance', '好慢': 'performance', '缓存': 'cache',
|
|
77
|
+
// security
|
|
78
|
+
'安全': 'security', '漏洞': 'vulnerability', '鉴权': 'auth', '认证': 'auth',
|
|
79
|
+
'授权': 'auth', '权限': 'auth', '泄露': 'security', '暴露': 'security',
|
|
80
|
+
'不安全': 'vulnerability',
|
|
81
|
+
// lint
|
|
82
|
+
'格式化': 'format', '代码风格': 'lint', '代码规范': 'lint', '类型检查': 'typecheck',
|
|
83
|
+
// design
|
|
84
|
+
'设计': 'design', '界面': 'ui', '前端': 'frontend', '样式': 'css',
|
|
85
|
+
'页面': 'frontend', '组件': 'component', '布局': 'layout',
|
|
86
|
+
// build
|
|
87
|
+
'构建': 'build', '编译': 'compile', '打包': 'bundle', '依赖': 'dependency',
|
|
88
|
+
// doc
|
|
89
|
+
'文档': 'documentation', '写文档': 'documentation', '文档化': 'documentation',
|
|
90
|
+
'注释': 'comment',
|
|
91
|
+
// infra
|
|
92
|
+
'容器': 'docker', '服务器': 'server', '运维': 'devops', '集群': 'cluster',
|
|
93
|
+
'监控': 'monitoring', '配置': 'config', '日志': 'logging',
|
|
94
|
+
// db
|
|
95
|
+
'数据库': 'database', '建表': 'database', '索引': 'database', '迁移': 'migration',
|
|
96
|
+
'查询慢': 'performance',
|
|
97
|
+
// api
|
|
98
|
+
'接口': 'api', '路由': 'route',
|
|
99
|
+
// plan
|
|
100
|
+
'规划': 'planning', '架构': 'architecture', '方案': 'plan', '设计方案': 'architecture',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Merge all CJK keys from both maps, longest-first to avoid partial matches
|
|
104
|
+
const ALL_CJK_KEYS = [...new Set([
|
|
105
|
+
...Object.keys(DISPATCH_SYNONYMS).filter(k => /[\u4e00-\u9fff\u3400-\u4dbf]/.test(k)),
|
|
106
|
+
...Object.keys(CJK_INTENT_MAP),
|
|
107
|
+
])].sort((a, b) => b.length - a.length);
|
|
108
|
+
|
|
109
|
+
function extractCJKTokens(text) {
|
|
110
|
+
const found = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const key of ALL_CJK_KEYS) {
|
|
113
|
+
if (text.includes(key)) {
|
|
114
|
+
if (!seen.has(key)) { seen.add(key); found.push(key); }
|
|
115
|
+
// Also inject English equivalent for direct FTS5 matching
|
|
116
|
+
const en = CJK_INTENT_MAP[key];
|
|
117
|
+
if (en && !seen.has(en)) { seen.add(en); found.push(en); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Query Building ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Expand a single token with synonyms for FTS5.
|
|
127
|
+
* @param {string} token Input token
|
|
128
|
+
* @returns {string} FTS5 OR group or bare token
|
|
129
|
+
*/
|
|
130
|
+
const MAX_SYNONYM_EXPANSION = 8;
|
|
131
|
+
|
|
132
|
+
function expandToken(token) {
|
|
133
|
+
const lower = token.toLowerCase();
|
|
134
|
+
const synonyms = DISPATCH_SYNONYMS[lower];
|
|
135
|
+
// CJK characters: pass through unquoted — FTS5 unicode61 tokenizer handles them natively.
|
|
136
|
+
// Quoting CJK tokens can interfere with tokenization.
|
|
137
|
+
const isSafe = t => /^[a-zA-Z0-9]+$/.test(t) || /[\u4e00-\u9fff\u3400-\u4dbf]/.test(t);
|
|
138
|
+
if (!synonyms || synonyms.length === 0) {
|
|
139
|
+
return isSafe(token) ? token : `"${token.replace(/"/g, '""')}"`;
|
|
140
|
+
}
|
|
141
|
+
// Cap synonym expansion to prevent BM25 precision dilution from overly broad OR groups
|
|
142
|
+
const capped = synonyms.slice(0, MAX_SYNONYM_EXPANSION);
|
|
143
|
+
const parts = [token, ...capped].map(t =>
|
|
144
|
+
isSafe(t) ? t : `"${t.replace(/"/g, '""')}"`
|
|
145
|
+
);
|
|
146
|
+
return `(${parts.join(' OR ')})`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build enhanced FTS5 query from context signals.
|
|
151
|
+
* Expands synonyms and joins with OR for broad matching.
|
|
152
|
+
* @param {object} signals Context signals from Tier 1
|
|
153
|
+
* @returns {string|null} FTS5 query string or null
|
|
154
|
+
*/
|
|
155
|
+
export function buildEnhancedQuery(signals) {
|
|
156
|
+
const parts = [];
|
|
157
|
+
|
|
158
|
+
// Column-targeted: route primary intent to intent_tags column (highest signal)
|
|
159
|
+
if (signals.primaryIntent) {
|
|
160
|
+
const expanded = expandToken(signals.primaryIntent.toLowerCase());
|
|
161
|
+
parts.push(`intent_tags:${expanded}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Secondary intents → also column-targeted to intent_tags (not general query).
|
|
165
|
+
// Previously these were general tokens that matched trigger_patterns (BM25 weight 5.0),
|
|
166
|
+
// causing secondary domain words (e.g. "api" in "Write documentation for the API module")
|
|
167
|
+
// to overpower the primary intent. Column-targeting keeps intent signal in the right lane.
|
|
168
|
+
//
|
|
169
|
+
// Note: signals.action (tool type: edit/bash/write) is NOT included — it's metadata
|
|
170
|
+
// about the tool being used, not what the user needs help with.
|
|
171
|
+
const generalTokens = new Set();
|
|
172
|
+
if (signals.intent) {
|
|
173
|
+
const intents = signals.intent.split(/[\s,]+/).filter(Boolean);
|
|
174
|
+
// Secondary intents: column-targeted but WITHOUT synonym expansion.
|
|
175
|
+
// This gives primary intent (expanded) higher BM25 weight than secondary intents (single token).
|
|
176
|
+
for (const t of intents.slice(signals.primaryIntent ? 1 : 0)) {
|
|
177
|
+
parts.push(`intent_tags:${t.toLowerCase()}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (signals.errorDomain) {
|
|
181
|
+
for (const t of signals.errorDomain.split(/[\s,]+/).filter(Boolean)) {
|
|
182
|
+
generalTokens.add(t.toLowerCase());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Column-targeted: route tech stack to domain_tags column
|
|
187
|
+
if (signals.techStack) {
|
|
188
|
+
for (const t of signals.techStack.split(/[\s,]+/).filter(Boolean)) {
|
|
189
|
+
parts.push(`domain_tags:${expandToken(t.toLowerCase())}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Add general tokens (expanded with synonyms)
|
|
194
|
+
for (const t of generalTokens) {
|
|
195
|
+
parts.push(expandToken(t));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (parts.length === 0) return null;
|
|
199
|
+
return parts.join(' OR ');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build FTS5 query from raw text (user prompt, tool description).
|
|
204
|
+
* Tokenizes, filters stop words, expands synonyms.
|
|
205
|
+
* @param {string} text Raw text input
|
|
206
|
+
* @returns {string|null} FTS5 query string or null
|
|
207
|
+
*/
|
|
208
|
+
export function buildQueryFromText(text) {
|
|
209
|
+
if (!text || typeof text !== 'string') return null;
|
|
210
|
+
|
|
211
|
+
const STOP_WORDS = new Set([
|
|
212
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
213
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
214
|
+
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
|
|
215
|
+
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'between',
|
|
216
|
+
'after', 'before', 'above', 'below', 'and', 'or', 'but', 'not', 'no',
|
|
217
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'my', 'your', 'his',
|
|
218
|
+
'her', 'our', 'their', 'me', 'him', 'us', 'them', 'i', 'you', 'he',
|
|
219
|
+
'she', 'we', 'they', 'what', 'which', 'who', 'when', 'where', 'how',
|
|
220
|
+
'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
|
|
221
|
+
'such', 'than', 'too', 'very', 'just', 'also', 'then', 'so', 'if',
|
|
222
|
+
'的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都',
|
|
223
|
+
'一', '一个', '上', '也', '这', '那', '你', '他', '她', '它', '们',
|
|
224
|
+
'把', '让', '给', '用', '来', '去', '做', '说', '要', '会', '能',
|
|
225
|
+
'帮', '帮我', '请', '下', '吧',
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
const cleaned = text.replace(/[{}()[\]^~*:@#$%&]/g, ' ').trim();
|
|
229
|
+
|
|
230
|
+
// Extract CJK compound words before whitespace split (Chinese has no spaces)
|
|
231
|
+
const cjkTokens = extractCJKTokens(cleaned);
|
|
232
|
+
|
|
233
|
+
const wsTokens = cleaned.split(/\s+/)
|
|
234
|
+
.filter(t => t.length > 1 && !STOP_WORDS.has(t.toLowerCase()) && !/^\d+$/.test(t));
|
|
235
|
+
|
|
236
|
+
// Merge: CJK tokens first (high signal), then whitespace tokens, deduplicated
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
const tokens = [];
|
|
239
|
+
for (const t of [...cjkTokens, ...wsTokens]) {
|
|
240
|
+
if (!seen.has(t)) { seen.add(t); tokens.push(t); }
|
|
241
|
+
}
|
|
242
|
+
tokens.splice(8); // Limit to 8 most relevant tokens
|
|
243
|
+
|
|
244
|
+
if (tokens.length === 0) return null;
|
|
245
|
+
|
|
246
|
+
const expanded = tokens.map(t => expandToken(t));
|
|
247
|
+
return expanded.join(' OR ');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── FTS5 Retrieval ──────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
// BM25 weights (8 columns, positional — must match FTS5 column order in registry.mjs):
|
|
253
|
+
// trigger_patterns(5), keywords(3), capability_summary(3),
|
|
254
|
+
// intent_tags(2), use_cases(2), domain_tags(1), tech_stack(1), name(1)
|
|
255
|
+
//
|
|
256
|
+
// Composite ranking formula:
|
|
257
|
+
// 40% BM25 text relevance
|
|
258
|
+
// 15% Star popularity (saturation normalization — diminishing returns after ~500 stars)
|
|
259
|
+
// 15% Success rate (Laplace smoothing — Beta prior α=1, β=1 for small-sample robustness)
|
|
260
|
+
// 10% Adoption rate (Laplace smoothing)
|
|
261
|
+
// 10% Cold start exploration bonus (UCB1-inspired — decays as recommend_count grows)
|
|
262
|
+
// -10% Negative feedback penalty (zombie recommendations: high recommend, near-zero adopt)
|
|
263
|
+
|
|
264
|
+
// Time-windowed behavioral signals: blend all-time rates (stability) with recent 30-day rates (freshness).
|
|
265
|
+
// recent_* subqueries return NULL when no recent invocations:
|
|
266
|
+
// COUNT(*)=0 → SUM(...)=NULL → (NULL+1.0)/(0+2.0) = NULL → COALESCE falls back to all-time only.
|
|
267
|
+
//
|
|
268
|
+
// Sign convention: bm25() returns NEGATIVE (more negative = more relevant).
|
|
269
|
+
// We keep the negative direction and SUBTRACT positive behavioral signals to make
|
|
270
|
+
// better resources more negative. ORDER BY ... ASC puts most negative (best) first.
|
|
271
|
+
const COMPOSITE_ORDER = `
|
|
272
|
+
ORDER BY (
|
|
273
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) * 0.4
|
|
274
|
+
- COALESCE(r.repo_stars * 1.0 / (r.repo_stars + 100.0), 0) * 0.15
|
|
275
|
+
- (
|
|
276
|
+
(r.success_count + 1.0) / (r.recommend_count + 2.0) * 0.5
|
|
277
|
+
+ COALESCE(
|
|
278
|
+
(SELECT (SUM(CASE WHEN i.outcome='success' THEN 1 ELSE 0 END) + 1.0)
|
|
279
|
+
/ (COUNT(*) + 2.0)
|
|
280
|
+
FROM invocations i WHERE i.resource_id = r.id
|
|
281
|
+
AND i.created_at > datetime('now', '-30 days')),
|
|
282
|
+
(r.success_count + 1.0) / (r.recommend_count + 2.0)
|
|
283
|
+
) * 0.5
|
|
284
|
+
) * 0.15
|
|
285
|
+
- (
|
|
286
|
+
(r.adopt_count + 1.0) / (r.recommend_count + 2.0) * 0.5
|
|
287
|
+
+ COALESCE(
|
|
288
|
+
(SELECT (SUM(CASE WHEN i.adopted=1 THEN 1 ELSE 0 END) + 1.0)
|
|
289
|
+
/ (COUNT(*) + 2.0)
|
|
290
|
+
FROM invocations i WHERE i.resource_id = r.id
|
|
291
|
+
AND i.created_at > datetime('now', '-30 days')),
|
|
292
|
+
(r.adopt_count + 1.0) / (r.recommend_count + 2.0)
|
|
293
|
+
) * 0.5
|
|
294
|
+
) * 0.10
|
|
295
|
+
- CASE WHEN r.recommend_count < 10
|
|
296
|
+
THEN 0.10 * (1.0 - r.recommend_count * 1.0 / 10.0)
|
|
297
|
+
ELSE 0 END
|
|
298
|
+
+ CASE WHEN r.recommend_count > 15
|
|
299
|
+
AND (r.adopt_count + 1.0) / (r.recommend_count + 2.0) < 0.1
|
|
300
|
+
THEN 0.10
|
|
301
|
+
ELSE 0 END
|
|
302
|
+
) ASC
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
const SEARCH_SQL = `
|
|
306
|
+
SELECT r.*,
|
|
307
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance
|
|
308
|
+
FROM resources_fts
|
|
309
|
+
JOIN resources r ON r.id = resources_fts.rowid
|
|
310
|
+
WHERE resources_fts MATCH ?
|
|
311
|
+
AND r.status = 'active'
|
|
312
|
+
${COMPOSITE_ORDER}
|
|
313
|
+
LIMIT ?
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
const SEARCH_BY_TYPE_SQL = `
|
|
317
|
+
SELECT r.*,
|
|
318
|
+
bm25(resources_fts, 5.0, 3.0, 3.0, 2.0, 2.0, 1.0, 1.0, 1.0) AS relevance
|
|
319
|
+
FROM resources_fts
|
|
320
|
+
JOIN resources r ON r.id = resources_fts.rowid
|
|
321
|
+
WHERE resources_fts MATCH ?
|
|
322
|
+
AND r.status = 'active'
|
|
323
|
+
AND r.type = ?
|
|
324
|
+
${COMPOSITE_ORDER}
|
|
325
|
+
LIMIT ?
|
|
326
|
+
`;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Search for resources using FTS5 with composite scoring.
|
|
330
|
+
* @param {Database} db Registry database
|
|
331
|
+
* @param {string} query FTS5 query string (already expanded)
|
|
332
|
+
* @param {object} [opts] Options
|
|
333
|
+
* @param {'skill'|'agent'} [opts.type] Filter by type
|
|
334
|
+
* @param {number} [opts.limit=3] Max results
|
|
335
|
+
* @returns {object[]} Array of matching resources with relevance scores
|
|
336
|
+
*/
|
|
337
|
+
export function retrieveResources(db, query, { type, limit = 3 } = {}) {
|
|
338
|
+
if (!query) return [];
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (type) {
|
|
342
|
+
return db.prepare(SEARCH_BY_TYPE_SQL).all(query, type, limit);
|
|
343
|
+
}
|
|
344
|
+
return db.prepare(SEARCH_SQL).all(query, limit);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// FTS5 query syntax error — try simpler query
|
|
347
|
+
debugCatch(e, 'retrieveResources');
|
|
348
|
+
try {
|
|
349
|
+
const simpleQuery = query.replace(/[()]/g, '').split(/\s+OR\s+/).slice(0, 3).join(' OR ');
|
|
350
|
+
if (type) {
|
|
351
|
+
return db.prepare(SEARCH_BY_TYPE_SQL).all(simpleQuery, type, limit);
|
|
352
|
+
}
|
|
353
|
+
return db.prepare(SEARCH_SQL).all(simpleQuery, limit);
|
|
354
|
+
} catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Search for resources using raw text (builds query automatically).
|
|
362
|
+
* Convenience wrapper combining buildQueryFromText + retrieveResources.
|
|
363
|
+
* @param {Database} db Registry database
|
|
364
|
+
* @param {string} text Raw search text
|
|
365
|
+
* @param {object} [opts] Options passed to retrieveResources
|
|
366
|
+
* @returns {object[]} Matching resources
|
|
367
|
+
*/
|
|
368
|
+
export function searchResources(db, text, opts) {
|
|
369
|
+
const query = buildQueryFromText(text);
|
|
370
|
+
if (!query) return [];
|
|
371
|
+
return retrieveResources(db, query, opts);
|
|
372
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// claude-mem-lite: Resource scanner — discovers skills/agents from filesystem
|
|
2
|
+
// Scans user local dirs and managed pre-installed dirs
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { debugCatch } from './utils.mjs';
|
|
9
|
+
import { DB_DIR } from './schema.mjs';
|
|
10
|
+
import { discoverFlat, discoverPlugin, discoverPlugins } from './resource-discovery.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} ScannedResource
|
|
14
|
+
* @property {string} name Resource name (directory name)
|
|
15
|
+
* @property {'skill'|'agent'} type Resource type
|
|
16
|
+
* @property {'preinstalled'|'user'} source Source origin
|
|
17
|
+
* @property {string} localPath Absolute path to resource directory
|
|
18
|
+
* @property {string} content Combined content of resource files
|
|
19
|
+
* @property {string} fileHash SHA-256 hash of content
|
|
20
|
+
* @property {string|null} repoUrl GitHub repo URL if preinstalled
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ─── Resource Location Convention ─────────────────────────────────────────────
|
|
24
|
+
// Two resource locations, differentiated by naming convention:
|
|
25
|
+
// managed/skills/{skill}/ → standalone skills, name: "{skill}"
|
|
26
|
+
// managed/agents/{plugin}/skills/ → plugin-attached skills, name: "{plugin}/{skill}"
|
|
27
|
+
// managed/agents/{plugin}/agents/ → plugin agents, name: "{plugin}/{agent}"
|
|
28
|
+
// Both scanned by same scanner; deduplication by "type:name" key.
|
|
29
|
+
|
|
30
|
+
// ─── Scan Sources ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the list of directories to scan for skill and agent resources.
|
|
34
|
+
* @param {string} dataDir Base data directory for managed resources
|
|
35
|
+
* @returns {{path: string, type: string, source: string, layout: string}[]}
|
|
36
|
+
*/
|
|
37
|
+
function getScanSources(dataDir) {
|
|
38
|
+
const home = homedir();
|
|
39
|
+
return [
|
|
40
|
+
// User local resources (highest priority) — flat layout
|
|
41
|
+
{ path: join(home, '.claude', 'skills'), type: 'skill', source: 'user', layout: 'flat' },
|
|
42
|
+
{ path: join(home, '.claude', 'agents'), type: 'agent', source: 'user', layout: 'flat' },
|
|
43
|
+
// Pre-installed managed resources
|
|
44
|
+
{ path: join(dataDir, 'managed', 'skills'), type: 'skill', source: 'preinstalled', layout: 'flat' },
|
|
45
|
+
// Agent plugins contain both agents and skills in nested structure
|
|
46
|
+
{ path: join(dataDir, 'managed', 'agents'), type: null, source: 'preinstalled', layout: 'plugins' },
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Content Reading ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the primary markdown content file from a resource directory.
|
|
54
|
+
* Searches skill.md, agent.md, README.md, then any .md or .yaml file.
|
|
55
|
+
* @param {string} dirPath Absolute path to the resource directory
|
|
56
|
+
* @returns {string} File content or empty string if not found
|
|
57
|
+
*/
|
|
58
|
+
function readResourceContent(dirPath) {
|
|
59
|
+
// Priority: skill.md / agent.md > README.md > first .md file
|
|
60
|
+
const candidates = ['skill.md', 'agent.md', 'SKILL.md', 'AGENT.md', 'README.md'];
|
|
61
|
+
|
|
62
|
+
for (const name of candidates) {
|
|
63
|
+
const fp = join(dirPath, name);
|
|
64
|
+
if (existsSync(fp)) {
|
|
65
|
+
try { return readFileSync(fp, 'utf8'); } catch { continue; }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fallback: first .md file found
|
|
70
|
+
try {
|
|
71
|
+
const files = readdirSync(dirPath).filter(f => f.endsWith('.md'));
|
|
72
|
+
if (files.length > 0) {
|
|
73
|
+
return readFileSync(join(dirPath, files[0]), 'utf8');
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
|
|
77
|
+
// Last resort: look for .yaml/.yml files (some agents use YAML)
|
|
78
|
+
try {
|
|
79
|
+
const yamlFiles = readdirSync(dirPath).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
|
80
|
+
if (yamlFiles.length > 0) {
|
|
81
|
+
return readFileSync(join(dirPath, yamlFiles[0]), 'utf8');
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute SHA-256 hash of content for change detection.
|
|
90
|
+
* @param {string} content File content to hash
|
|
91
|
+
* @returns {string|null} Hex-encoded SHA-256 hash, or null if content is empty
|
|
92
|
+
*/
|
|
93
|
+
function computeHash(content) {
|
|
94
|
+
if (!content) return null;
|
|
95
|
+
return createHash('sha256').update(content).digest('hex');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Directory Scanning ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan a single directory for resources.
|
|
102
|
+
* Each subdirectory (or .md file) is treated as one resource.
|
|
103
|
+
* Delegates path discovery to discoverFlat(), then reads content via buildScannedResource().
|
|
104
|
+
* @param {string} dirPath Directory to scan
|
|
105
|
+
* @param {'skill'|'agent'} type Resource type
|
|
106
|
+
* @param {'preinstalled'|'user'} source Source origin
|
|
107
|
+
* @returns {ScannedResource[]} Array of discovered resources
|
|
108
|
+
*/
|
|
109
|
+
export function scanDirectory(dirPath, type, source) {
|
|
110
|
+
const discovered = discoverFlat(dirPath, type, { strict: false, includeFiles: true });
|
|
111
|
+
const resources = [];
|
|
112
|
+
for (const d of discovered) {
|
|
113
|
+
const res = buildScannedResource(d, source);
|
|
114
|
+
if (res) resources.push(res);
|
|
115
|
+
}
|
|
116
|
+
return resources;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Plugin Scanning ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a ScannedResource from a DiscoveredResource by reading content.
|
|
123
|
+
* @param {{type: string, name: string, absPath: string}} discovered
|
|
124
|
+
* @param {'preinstalled'|'user'} source
|
|
125
|
+
* @returns {ScannedResource|null}
|
|
126
|
+
*/
|
|
127
|
+
function buildScannedResource(discovered, source) {
|
|
128
|
+
try {
|
|
129
|
+
const stat = statSync(discovered.absPath);
|
|
130
|
+
let content;
|
|
131
|
+
if (stat.isDirectory()) {
|
|
132
|
+
content = readResourceContent(discovered.absPath);
|
|
133
|
+
} else {
|
|
134
|
+
content = readFileSync(discovered.absPath, 'utf8');
|
|
135
|
+
}
|
|
136
|
+
if (!content || content.length < 10) return null;
|
|
137
|
+
return {
|
|
138
|
+
name: discovered.name,
|
|
139
|
+
type: discovered.type,
|
|
140
|
+
source,
|
|
141
|
+
localPath: discovered.absPath,
|
|
142
|
+
content,
|
|
143
|
+
fileHash: computeHash(content),
|
|
144
|
+
repoUrl: null,
|
|
145
|
+
};
|
|
146
|
+
} catch (e) {
|
|
147
|
+
debugCatch(e, `buildScannedResource(${discovered.absPath})`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Scan a single agent plugin directory for nested agents and skills.
|
|
154
|
+
* @param {string} pluginDir Absolute path to plugin directory
|
|
155
|
+
* @param {string} pluginName Plugin directory name
|
|
156
|
+
* @param {'preinstalled'|'user'} source Source origin
|
|
157
|
+
* @returns {ScannedResource[]} Discovered agents and skills
|
|
158
|
+
*/
|
|
159
|
+
export function scanPluginResources(pluginDir, pluginName, source) {
|
|
160
|
+
const discovered = discoverPlugin(pluginDir, pluginName);
|
|
161
|
+
const resources = [];
|
|
162
|
+
for (const d of discovered) {
|
|
163
|
+
const res = buildScannedResource(d, source);
|
|
164
|
+
if (res) resources.push(res);
|
|
165
|
+
}
|
|
166
|
+
return resources;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Scan a plugins directory (e.g. managed/agents/) for all plugin subdirs.
|
|
171
|
+
* @param {string} dirPath Directory containing plugin directories
|
|
172
|
+
* @param {'preinstalled'|'user'} source Source origin
|
|
173
|
+
* @returns {ScannedResource[]} All discovered plugin resources
|
|
174
|
+
*/
|
|
175
|
+
export function scanPluginsDirectory(dirPath, source) {
|
|
176
|
+
const discovered = discoverPlugins(dirPath);
|
|
177
|
+
const resources = [];
|
|
178
|
+
for (const d of discovered) {
|
|
179
|
+
const res = buildScannedResource(d, source);
|
|
180
|
+
if (res) resources.push(res);
|
|
181
|
+
}
|
|
182
|
+
return resources;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Main Scan ───────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Scan all resource sources and return discovered resources.
|
|
189
|
+
* Deduplicates by (type, name) — user resources take priority over preinstalled.
|
|
190
|
+
* @param {object} [config] Configuration
|
|
191
|
+
* @param {string} [config.dataDir] Data directory (defaults to DB_DIR)
|
|
192
|
+
* @returns {ScannedResource[]} All discovered resources
|
|
193
|
+
*/
|
|
194
|
+
export function scanAllResources(config = {}) {
|
|
195
|
+
const dataDir = config.dataDir || DB_DIR;
|
|
196
|
+
const sources = getScanSources(dataDir);
|
|
197
|
+
const seen = new Map(); // key: "type:name" -> resource
|
|
198
|
+
|
|
199
|
+
for (const src of sources) {
|
|
200
|
+
const resources = src.layout === 'plugins'
|
|
201
|
+
? scanPluginsDirectory(src.path, src.source)
|
|
202
|
+
: scanDirectory(src.path, src.type, src.source);
|
|
203
|
+
for (const res of resources) {
|
|
204
|
+
const key = `${res.type}:${res.name}`;
|
|
205
|
+
// User resources override preinstalled (scanned first due to source order)
|
|
206
|
+
if (!seen.has(key)) {
|
|
207
|
+
seen.set(key, res);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return [...seen.values()];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compare scanned resources against DB state to find what needs indexing.
|
|
217
|
+
* @param {Database} db Registry database
|
|
218
|
+
* @param {ScannedResource[]} scanned Scanned resources
|
|
219
|
+
* @returns {{toIndex: ScannedResource[], toDisable: object[]}} Resources needing action
|
|
220
|
+
*/
|
|
221
|
+
export function diffResources(db, scanned) {
|
|
222
|
+
const existing = new Map();
|
|
223
|
+
const rows = db.prepare('SELECT id, type, name, file_hash, status FROM resources').all();
|
|
224
|
+
for (const r of rows) existing.set(`${r.type}:${r.name}`, r);
|
|
225
|
+
|
|
226
|
+
const toIndex = [];
|
|
227
|
+
const scannedKeys = new Set();
|
|
228
|
+
|
|
229
|
+
for (const res of scanned) {
|
|
230
|
+
const key = `${res.type}:${res.name}`;
|
|
231
|
+
scannedKeys.add(key);
|
|
232
|
+
const ex = existing.get(key);
|
|
233
|
+
|
|
234
|
+
if (!ex) {
|
|
235
|
+
// New resource
|
|
236
|
+
toIndex.push(res);
|
|
237
|
+
} else if (ex.file_hash !== res.fileHash) {
|
|
238
|
+
// Content changed
|
|
239
|
+
toIndex.push(res);
|
|
240
|
+
}
|
|
241
|
+
// else: unchanged, skip
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Resources in DB but not on filesystem → disable
|
|
245
|
+
const toDisable = [];
|
|
246
|
+
for (const [key, row] of existing) {
|
|
247
|
+
if (!scannedKeys.has(key) && row.status === 'active') {
|
|
248
|
+
toDisable.push(row);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { toIndex, toDisable };
|
|
253
|
+
}
|