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.
@@ -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
+ }