claude-mem-lite 2.26.1 → 2.28.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.
@@ -0,0 +1,352 @@
1
+ // claude-mem-lite: Registry importer — tree discovery, frontmatter parsing, keyword extraction, GitHub import pipeline
2
+ // GitHub API helpers (parseGitHubUrl, buildTreeUrl, buildContentUrl, buildHeaders)
3
+ // are in registry-github.mjs.
4
+
5
+ import { parseGitHubUrl, buildTreeUrl, buildContentUrl, buildRepoUrl, buildHeaders } from './registry-github.mjs';
6
+ import { upsertResource } from './registry.mjs';
7
+ import { debugLog } from './utils.mjs';
8
+ import { createHash } from 'crypto';
9
+ import { mkdirSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+
13
+ const MANAGED_DIR = join(homedir(), '.claude-mem-lite', 'managed');
14
+
15
+ // ─── Tree Discovery ─────────────────────────────────────────────────────────
16
+
17
+ // Patterns: flat (skills/name/SKILL.md), plugin (plugins/x/skills/y/SKILL.md),
18
+ // agent (agents/name/AGENT.md), root (./SKILL.md)
19
+ const SKILL_RE = /(?:^|\/)(skills\/([^/]+)\/SKILL\.md)$/;
20
+ const AGENT_RE = /(?:^|\/)(agents\/([^/]+)\/AGENT\.md)$/;
21
+ const PLUGIN_SKILL_RE = /^plugins\/([^/]+)\/skills\/([^/]+)\/SKILL\.md$/;
22
+ const ROOT_SKILL_RE = /^SKILL\.md$/;
23
+
24
+ /**
25
+ * Discover skills/agents from a GitHub tree API response.
26
+ * Supports flat (skills/name/SKILL.md), plugin (plugins/x/skills/y/SKILL.md),
27
+ * and root (./SKILL.md) layouts.
28
+ * @param {object} treeData GitHub API tree response { tree: [{ path, type }] }
29
+ * @param {string} pathFilter Only include paths under this prefix (empty = all)
30
+ * @returns {Array<{ name: string, type: 'skill'|'agent', filePath: string }>}
31
+ */
32
+ export function discoverFromTree(treeData, pathFilter) {
33
+ const results = [];
34
+ if (!treeData?.tree) return results;
35
+
36
+ for (const item of treeData.tree) {
37
+ if (item.type !== 'blob') continue;
38
+ const p = item.path;
39
+
40
+ // Apply path filter
41
+ if (pathFilter && !p.startsWith(pathFilter)) continue;
42
+
43
+ // Plugin-nested skill: plugins/x/skills/y/SKILL.md → name = "x/y"
44
+ const pluginMatch = p.match(PLUGIN_SKILL_RE);
45
+ if (pluginMatch) {
46
+ results.push({ name: `${pluginMatch[1]}/${pluginMatch[2]}`, type: 'skill', filePath: p });
47
+ continue;
48
+ }
49
+
50
+ // Flat skill: skills/name/SKILL.md → name = "name"
51
+ const skillMatch = p.match(SKILL_RE);
52
+ if (skillMatch) {
53
+ results.push({ name: skillMatch[2], type: 'skill', filePath: p });
54
+ continue;
55
+ }
56
+
57
+ // Agent: agents/name/AGENT.md → name = "name"
58
+ const agentMatch = p.match(AGENT_RE);
59
+ if (agentMatch) {
60
+ results.push({ name: agentMatch[2], type: 'agent', filePath: p });
61
+ continue;
62
+ }
63
+
64
+ // Root-level SKILL.md
65
+ if (ROOT_SKILL_RE.test(p)) {
66
+ results.push({ name: 'root', type: 'skill', filePath: p });
67
+ continue;
68
+ }
69
+
70
+ // Generic: any-dir/SKILL.md or any-dir/AGENT.md (non-standard layouts)
71
+ const genericSkill = p.match(/^([^/]+)\/SKILL\.md$/);
72
+ if (genericSkill) {
73
+ results.push({ name: genericSkill[1], type: 'skill', filePath: p });
74
+ continue;
75
+ }
76
+ const genericAgent = p.match(/^([^/]+)\/AGENT\.md$/);
77
+ if (genericAgent) {
78
+ results.push({ name: genericAgent[1], type: 'agent', filePath: p });
79
+ continue;
80
+ }
81
+ }
82
+
83
+ return results;
84
+ }
85
+
86
+ // ─── YAML Frontmatter Parser ────────────────────────────────────────────────
87
+ // Lightweight YAML subset parser for skill/agent frontmatter.
88
+ // Known limitations: does not handle YAML arrays (- item), nested objects,
89
+ // or unquoted values containing colons (e.g. bare URLs). For such fields,
90
+ // wrap the value in quotes in the frontmatter: url: "https://..."
91
+
92
+ /**
93
+ * Parse YAML frontmatter from SKILL.md / AGENT.md content.
94
+ * Handles basic key: value, multiline (|, >), JSON arrays ([...]), quoted strings.
95
+ * @param {string} content Full file content
96
+ * @returns {{ frontmatter: Record<string, any>, body: string }}
97
+ */
98
+ export function parseFrontmatter(content) {
99
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
100
+ if (!match) return { frontmatter: {}, body: content };
101
+
102
+ const raw = match[1];
103
+ const body = content.slice(match[0].length).trim();
104
+ const fm = {};
105
+ let currentKey = null, currentValue = '', inMultiline = false;
106
+
107
+ for (const line of raw.split('\n')) {
108
+ if (inMultiline && (line.startsWith(' ') || line.startsWith('\t') || line.trim() === '')) {
109
+ currentValue += ' ' + line.trim();
110
+ continue;
111
+ }
112
+ if (inMultiline && currentKey) { fm[currentKey] = currentValue.trim(); inMultiline = false; }
113
+
114
+ const kv = line.match(/^(\w[\w-]*)\s*:\s*(.*)/);
115
+ if (kv) {
116
+ currentKey = kv[1];
117
+ let val = kv[2].trim();
118
+ if (val === '|' || val === '>') { inMultiline = true; currentValue = ''; continue; }
119
+ if (val.startsWith('[') && val.endsWith(']')) {
120
+ try { fm[currentKey] = JSON.parse(val); } catch { fm[currentKey] = val; }
121
+ continue;
122
+ }
123
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
124
+ val = val.slice(1, -1);
125
+ if (currentKey === 'description' && val) { inMultiline = true; currentValue = val; continue; }
126
+ fm[currentKey] = val;
127
+ }
128
+ }
129
+ if (inMultiline && currentKey) fm[currentKey] = currentValue.trim();
130
+ return { frontmatter: fm, body };
131
+ }
132
+
133
+ // ─── Keyword Extraction ─────────────────────────────────────────────────────
134
+
135
+ const STOP_WORDS = new Set([
136
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
137
+ 'by', 'from', 'up', 'about', 'into', 'through', 'during', 'before', 'after',
138
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do',
139
+ 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'shall',
140
+ 'not', 'no', 'nor', 'so', 'if', 'then', 'than', 'that', 'this', 'these', 'those',
141
+ 'it', 'its', 'as', 'such', 'which', 'who', 'whom', 'what', 'when', 'where', 'how',
142
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'any',
143
+ 'can', 'use', 'using', 'used', 'also', 'just', 'very', 'only', 'own', 'same',
144
+ 'make', 'like', 'get', 'set', 'new', 'one', 'two', 'see', 'way', 'well',
145
+ ]);
146
+
147
+ const INTENT_MAP = {
148
+ test: [/\btest\b/i, /\btdd\b/i, /\bunit\s*test/i, /\be2e\b/i, /\bspec\b/i, /\bcoverage\b/i],
149
+ debug: [/\bdebug\b/i, /\btroubleshoot\b/i, /\bdiagnose\b/i, /\berror\b/i, /\bbug\b/i],
150
+ deploy: [/\bdeploy\b/i, /\bci[\s/]*cd\b/i, /\bpipeline\b/i, /\brelease\b/i, /\bship\b/i, /\bpublish\b/i],
151
+ review: [/\breview\b/i, /\baudit\b/i, /\blint\b/i, /\binspect\b/i, /code\s*quality/i],
152
+ generate: [/\bcreate\b/i, /\bscaffold\b/i, /\bgenerate\b/i, /\bboilerplate\b/i],
153
+ refactor: [/\brefactor\b/i, /\boptimize\b/i, /\bclean\s*up\b/i, /\bsimplify\b/i],
154
+ document: [/\bdocument\b/i, /\bdocs?\b/i, /\breadme\b/i, /\bjsdoc\b/i],
155
+ plan: [/\bplan\b/i, /\bdesign\b/i, /\barchitect\b/i, /\bblueprint\b/i],
156
+ security: [/\bsecurity\b/i, /\bvulnerab/i, /\bauthenticat/i, /\bencrypt/i],
157
+ performance:[/\bperformance\b/i, /\bprofil/i, /\bbenchmark\b/i, /\blatency\b/i],
158
+ migrate: [/\bmigrat/i, /\bupgrad/i, /\blegacy\b/i],
159
+ };
160
+
161
+ const DOMAIN_PATTERNS = {
162
+ frontend: [/\breact\b/i, /\bvue\b/i, /\bangular\b/i, /\bsvelte\b/i, /\bnext\.?js\b/i, /\bcss\b/i, /\btailwind\b/i, /\bhtml\b/i],
163
+ backend: [/\bexpress\b/i, /\bfastapi\b/i, /\bdjango\b/i, /\bflask\b/i, /\brails\b/i, /\bspring\b/i],
164
+ database: [/\bpostgres/i, /\bmysql\b/i, /\bmongodb\b/i, /\bredis\b/i, /\bsqlite\b/i, /\bsql\b/i],
165
+ infrastructure: [/\bdocker\b/i, /\bkubernetes\b/i, /\bterraform\b/i, /\bansible\b/i, /\bcloud\b/i, /\baws\b/i, /\bgcp\b/i, /\bazure\b/i],
166
+ javascript: [/\bjavascript\b/i, /\btypescript\b/i, /\bnode\b/i, /\bnpm\b/i, /\besm\b/i],
167
+ python: [/\bpython\b/i, /\bpip\b/i, /\bpydantic\b/i, /\bpoetry\b/i],
168
+ testing: [/\bjest\b/i, /\bvitest\b/i, /\bpytest\b/i, /\bcypress\b/i, /\bplaywright\b/i],
169
+ security: [/\boauth\b/i, /\bjwt\b/i, /\bssl\b/i, /\btls\b/i, /\brbac\b/i],
170
+ ml: [/\bmachine\s*learning\b/i, /\bneural\b/i, /\btensor/i, /\bpytorch\b/i, /\bllm\b/i],
171
+ mobile: [/\bios\b/i, /\bandroid\b/i, /react.native/i, /\bflutter\b/i, /\bswift\b/i],
172
+ };
173
+
174
+ /**
175
+ * Extract keywords, intent tags, and domain tags from content.
176
+ * @param {string} content Full text
177
+ * @returns {{ keywords: string, intentTags: string, domainTags: string }}
178
+ */
179
+ export function extractKeywords(content) {
180
+ if (!content) return { keywords: '', intentTags: '', domainTags: '' };
181
+
182
+ const text = content.toLowerCase();
183
+
184
+ // ── Keywords: stop-word filtered frequency counting, top 10 ────────────
185
+ const words = text.match(/\b[a-z][a-z0-9]{2,}\b/g) || [];
186
+ const freq = {};
187
+ for (const w of words) {
188
+ if (!STOP_WORDS.has(w)) freq[w] = (freq[w] || 0) + 1;
189
+ }
190
+ const keywords = Object.entries(freq)
191
+ .sort(([, a], [, b]) => b - a)
192
+ .slice(0, 10)
193
+ .map(([w]) => w)
194
+ .join(' ');
195
+
196
+ // ── Intent tags ───────────────────────────────────────────────────────
197
+ const intents = [];
198
+ for (const [intent, patterns] of Object.entries(INTENT_MAP)) {
199
+ if (patterns.some(re => re.test(text))) intents.push(intent);
200
+ }
201
+ const intentTags = intents.join(' ');
202
+
203
+ // ── Domain tags ───────────────────────────────────────────────────────
204
+ const domains = [];
205
+ for (const [domain, patterns] of Object.entries(DOMAIN_PATTERNS)) {
206
+ if (patterns.some(re => re.test(text))) domains.push(domain);
207
+ }
208
+ const domainTags = domains.join(' ');
209
+
210
+ return { keywords, intentTags, domainTags };
211
+ }
212
+
213
+ // ─── GitHub Import Pipeline ─────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Import skills/agents from a GitHub URL into the registry.
217
+ * Stage 1 only — pure code, no LLM.
218
+ * @param {Database} db Registry database
219
+ * @param {string} url GitHub URL
220
+ * @param {object} opts Options
221
+ * @param {Function} opts.fetchFn Override fetch function (for testing)
222
+ * @param {string} opts.managedDir Override managed directory (for testing)
223
+ * @returns {Promise<Array<{ name: string, type: string, id: number }>>}
224
+ */
225
+ export async function importFromGitHub(db, url, opts = {}) {
226
+ const fetchFn = opts.fetchFn || globalThis.fetch;
227
+ const managedDir = opts.managedDir || MANAGED_DIR;
228
+ const headers = buildHeaders();
229
+
230
+ // 1. Parse GitHub URL
231
+ const parsed = parseGitHubUrl(url);
232
+ if (!parsed) throw new Error('Invalid GitHub URL');
233
+ const { owner, repo, branch, path: pathFilter } = parsed;
234
+
235
+ // 2. Fetch repo metadata (stars, forks, updated_at)
236
+ const repoResp = await fetchFn(buildRepoUrl(owner, repo), { headers });
237
+ if (!repoResp.ok) {
238
+ if (repoResp.status === 404) throw new Error(`Repository not found: ${owner}/${repo}`);
239
+ if (repoResp.status === 403) throw new Error(`GitHub API rate limit exceeded`);
240
+ throw new Error(`GitHub API error: ${repoResp.status}`);
241
+ }
242
+ const repoMeta = await repoResp.json();
243
+ const repoStars = repoMeta.stargazers_count || 0;
244
+ const repoForks = repoMeta.forks_count || 0;
245
+ const repoUpdatedAt = repoMeta.updated_at || null;
246
+
247
+ // 3. Fetch file tree via GitHub API (recursive)
248
+ const treeResp = await fetchFn(buildTreeUrl(owner, repo, branch), { headers });
249
+ if (!treeResp.ok) {
250
+ if (treeResp.status === 404) throw new Error(`Branch not found: ${branch}`);
251
+ if (treeResp.status === 403) throw new Error(`GitHub API rate limit exceeded`);
252
+ throw new Error(`GitHub API error: ${treeResp.status}`);
253
+ }
254
+ const treeData = await treeResp.json();
255
+
256
+ // 4. Discover skills/agents from tree
257
+ const discovered = discoverFromTree(treeData, pathFilter);
258
+ if (discovered.length === 0) return [];
259
+
260
+ const repoUrl = `https://github.com/${owner}/${repo}`;
261
+ const results = [];
262
+
263
+ // 5. Process each discovered item
264
+ for (const item of discovered) {
265
+ try {
266
+ // 5a. Fetch content via raw GitHub URL
267
+ const contentUrl = buildContentUrl(owner, repo, branch, item.filePath);
268
+ const contentResp = await fetchFn(contentUrl, { headers });
269
+ if (!contentResp.ok) {
270
+ debugLog('WARN', 'importer', `Failed to fetch ${item.filePath}: ${contentResp.status}`);
271
+ continue;
272
+ }
273
+ const content = await contentResp.text();
274
+
275
+ // 5b. Parse frontmatter
276
+ const { frontmatter, body } = parseFrontmatter(content);
277
+
278
+ // Root skill naming: use frontmatter name if present, else repo name for root, else discovered name
279
+ const name = frontmatter.name || (item.name === 'root' ? repo : item.name);
280
+ const description = frontmatter.description || '';
281
+ const fullText = `${name} ${description} ${body}`;
282
+
283
+ // 5c. Extract keywords/intents/domains
284
+ const { keywords, intentTags, domainTags } = extractKeywords(fullText);
285
+
286
+ // 5d. SHA-256 hash for dedup
287
+ const fileHash = createHash('sha256').update(content).digest('hex');
288
+ const existing = db.prepare(
289
+ 'SELECT file_hash FROM resources WHERE type = ? AND name = ?'
290
+ ).get(item.type, name);
291
+ if (existing && existing.file_hash === fileHash) {
292
+ debugLog('DEBUG', 'importer', `Skipping ${name} — unchanged`);
293
+ continue;
294
+ }
295
+
296
+ // 5e. Download to managed directory
297
+ const typeDir = item.type === 'agent' ? 'agents' : 'skills';
298
+ const destDir = join(managedDir, typeDir, name);
299
+ mkdirSync(destDir, { recursive: true });
300
+ const fileName = item.type === 'agent' ? 'AGENT.md' : 'SKILL.md';
301
+ writeFileSync(join(destDir, fileName), content, 'utf8');
302
+
303
+ // 5f. Upsert to registry DB
304
+ const resourceId = upsertResource(db, {
305
+ name,
306
+ type: item.type,
307
+ status: 'active',
308
+ source: 'github',
309
+ repo_url: repoUrl,
310
+ repo_stars: repoStars,
311
+ local_path: join(destDir, fileName),
312
+ file_hash: fileHash,
313
+ invocation_name: frontmatter['invocation-name'] || frontmatter.invocation_name || '',
314
+ intent_tags: intentTags,
315
+ domain_tags: domainTags,
316
+ action_type: frontmatter.action_type || frontmatter['action-type'] || '',
317
+ trigger_patterns: frontmatter.trigger_patterns || frontmatter['trigger-patterns'] || '',
318
+ capability_summary: description,
319
+ input_type: frontmatter.input_type || frontmatter['input-type'] || '',
320
+ output_type: frontmatter.output_type || frontmatter['output-type'] || '',
321
+ prerequisites: frontmatter.prerequisites || '{}',
322
+ keywords,
323
+ tech_stack: frontmatter.tech_stack || frontmatter['tech-stack'] || '',
324
+ use_cases: frontmatter.use_cases || frontmatter['use-cases'] || '',
325
+ complexity: frontmatter.complexity || 'intermediate',
326
+ quality_tier: 'community',
327
+ indexed_at: new Date().toISOString(),
328
+ });
329
+
330
+ // 5g. Update repo_forks and repo_updated_at (not in upsert SQL)
331
+ db.prepare(
332
+ 'UPDATE resources SET repo_forks = ?, repo_updated_at = ?, quality_tier = ? WHERE id = ?'
333
+ ).run(repoForks, repoUpdatedAt, 'community', resourceId);
334
+
335
+ results.push({ name, type: item.type, id: resourceId });
336
+ debugLog('INFO', 'importer', `Imported ${item.type}:${name} (id=${resourceId})`);
337
+ } catch (err) {
338
+ debugLog('ERROR', 'importer', `Failed to import ${item.name}: ${err.message}`);
339
+ // Skip individual failures, continue with next
340
+ }
341
+ }
342
+
343
+ // 6. Rebuild FTS5 index
344
+ try {
345
+ db.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')");
346
+ } catch (err) {
347
+ debugLog('WARN', 'importer', `FTS rebuild failed: ${err.message}`);
348
+ }
349
+
350
+ // 7. Return imported resources
351
+ return results;
352
+ }
package/registry.mjs CHANGED
@@ -15,7 +15,7 @@ const RESOURCES_SCHEMA = `
15
15
  type TEXT NOT NULL CHECK(type IN ('skill','agent')),
16
16
  status TEXT NOT NULL DEFAULT 'active'
17
17
  CHECK(status IN ('active','disabled','error','indexing')),
18
- source TEXT NOT NULL CHECK(source IN ('preinstalled','user')),
18
+ source TEXT NOT NULL CHECK(source IN ('preinstalled','user','github')),
19
19
  repo_url TEXT,
20
20
  repo_stars INTEGER DEFAULT 0,
21
21
  local_path TEXT NOT NULL,
@@ -47,7 +47,11 @@ const RESOURCES_SCHEMA = `
47
47
  recommendation_mode TEXT DEFAULT 'proactive',
48
48
  indexed_at TEXT,
49
49
  created_at TEXT DEFAULT (datetime('now')),
50
- updated_at TEXT DEFAULT (datetime('now'))
50
+ updated_at TEXT DEFAULT (datetime('now')),
51
+ enrichment_status TEXT DEFAULT NULL,
52
+ enriched_at INTEGER DEFAULT NULL,
53
+ repo_updated_at TEXT DEFAULT NULL,
54
+ repo_forks INTEGER DEFAULT 0
51
55
  );
52
56
 
53
57
  CREATE UNIQUE INDEX IF NOT EXISTS idx_res_type_name
@@ -182,10 +186,40 @@ export function ensureRegistryDb(dbPath) {
182
186
  if (!resCols.has('quality_tier')) db.exec("ALTER TABLE resources ADD COLUMN quality_tier TEXT DEFAULT 'community'");
183
187
  if (!resCols.has('popularity_score')) db.exec("ALTER TABLE resources ADD COLUMN popularity_score REAL DEFAULT 0");
184
188
  if (!resCols.has('personal_score')) db.exec("ALTER TABLE resources ADD COLUMN personal_score REAL DEFAULT 0");
189
+ if (!resCols.has('enrichment_status')) db.exec("ALTER TABLE resources ADD COLUMN enrichment_status TEXT DEFAULT NULL");
190
+ if (!resCols.has('enriched_at')) db.exec("ALTER TABLE resources ADD COLUMN enriched_at INTEGER DEFAULT NULL");
191
+ if (!resCols.has('repo_updated_at')) db.exec("ALTER TABLE resources ADD COLUMN repo_updated_at TEXT DEFAULT NULL");
192
+ if (!resCols.has('repo_forks')) db.exec("ALTER TABLE resources ADD COLUMN repo_forks INTEGER DEFAULT 0");
185
193
  // Auto-set quality_tier for installed preinstalled resources
186
194
  db.exec("UPDATE resources SET quality_tier = 'installed' WHERE source = 'preinstalled' AND quality_tier = 'community'");
187
195
  } catch (e) { debugCatch(e, 'resources-column-migration'); }
188
196
 
197
+ // Migrate: add 'github' to source CHECK constraint (required for smart import)
198
+ // Must disable FK checks during table recreation (RENAME triggers FK validation)
199
+ try {
200
+ const resSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='resources'`).get();
201
+ if (resSchema?.sql && !resSchema.sql.includes("'github'")) {
202
+ db.pragma('foreign_keys = OFF');
203
+ db.transaction(() => {
204
+ const hasOld = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_old'`).get();
205
+ if (hasOld) db.exec(`DROP TABLE resources_old`);
206
+ // Drop FTS triggers first (reference resources table)
207
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_insert`);
208
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_update`);
209
+ db.exec(`DROP TRIGGER IF EXISTS res_fts_delete`);
210
+ db.exec(`ALTER TABLE resources RENAME TO resources_old`);
211
+ db.exec(RESOURCES_SCHEMA);
212
+ // Copy all existing data
213
+ const cols = db.prepare("PRAGMA table_info(resources_old)").all().map(c => c.name);
214
+ const newCols = new Set(db.prepare("PRAGMA table_info(resources)").all().map(c => c.name));
215
+ const common = cols.filter(c => newCols.has(c)).join(', ');
216
+ db.exec(`INSERT INTO resources (${common}) SELECT ${common} FROM resources_old`);
217
+ db.exec(`DROP TABLE resources_old`);
218
+ })();
219
+ db.pragma('foreign_keys = ON');
220
+ }
221
+ } catch (e) { debugCatch(e, 'resources-source-check-migration'); }
222
+
189
223
  // FTS5: create if not exists
190
224
  const hasFts = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='resources_fts'`).get();
191
225
  if (!hasFts) {
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // claude-mem-lite: PreToolUse Skill bridge — loads managed skills from registry
3
+ // Intercepts Skill("name") calls for skills in ~/.claude-mem-lite/managed/
4
+ // Lightweight standalone (~30ms): only imports better-sqlite3, fs, path, os
5
+
6
+ import { existsSync, readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ const REGISTRY_DB_PATH = join(homedir(), '.claude-mem-lite', 'resource-registry.db');
11
+ const MANAGED_MARKER = '/.claude-mem-lite/managed/';
12
+
13
+ try {
14
+ // Skip if recursive hook
15
+ if (process.env.CLAUDE_MEM_HOOK_RUNNING) process.exit(0);
16
+
17
+ // Read stdin
18
+ let input = '';
19
+ for await (const chunk of process.stdin) input += chunk;
20
+
21
+ // Parse event
22
+ let skillName;
23
+ try {
24
+ const event = JSON.parse(input);
25
+ skillName = event.tool_input?.skill;
26
+ } catch { process.exit(0); }
27
+
28
+ if (!skillName || typeof skillName !== 'string') process.exit(0);
29
+
30
+ // Skip if registry DB doesn't exist
31
+ if (!existsSync(REGISTRY_DB_PATH)) process.exit(0);
32
+
33
+ // Open DB readonly
34
+ const Database = (await import('better-sqlite3')).default;
35
+ let db;
36
+ try {
37
+ db = new Database(REGISTRY_DB_PATH, { readonly: true });
38
+ db.pragma('busy_timeout = 1000');
39
+ } catch { process.exit(0); }
40
+
41
+ try {
42
+ // Query: find by name or invocation_name, ONLY if managed path
43
+ const row = db.prepare(`
44
+ SELECT name, local_path FROM resources
45
+ WHERE status = 'active'
46
+ AND (name = ? OR invocation_name = ?)
47
+ AND local_path LIKE ?
48
+ LIMIT 1
49
+ `).get(skillName, skillName, `%${MANAGED_MARKER}%`);
50
+
51
+ if (!row || !row.local_path) process.exit(0);
52
+
53
+ // Resolve path: directory skills → SKILL.md (agents always have full .md paths)
54
+ let skillPath = row.local_path;
55
+ if (!skillPath.endsWith('.md')) {
56
+ const candidate = join(skillPath, 'SKILL.md');
57
+ if (existsSync(candidate)) skillPath = candidate;
58
+ }
59
+
60
+ if (!existsSync(skillPath)) process.exit(0);
61
+
62
+ // Read and output
63
+ const content = readFileSync(skillPath, 'utf8');
64
+ // Token budget: ~4 chars per token, 4000 token limit = 16000 chars
65
+ if (content.length > 16000) {
66
+ const summary = content.slice(0, 800);
67
+ console.log(`<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Use mem_use(name="${row.name}") to load full content.`);
68
+ } else {
69
+ console.log(`<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`);
70
+ }
71
+ } catch {
72
+ // Silent failure — never block Skill tool
73
+ } finally {
74
+ try { db.close(); } catch {}
75
+ }
76
+ } catch {
77
+ // Top-level catch — exit 0 no matter what
78
+ }
@@ -34,10 +34,25 @@ export const INTENTS = [
34
34
  ];
35
35
 
36
36
  export function detectIntent(text) {
37
+ // Collect all matching intents (patterns may overlap)
38
+ const matches = [];
37
39
  for (const intent of INTENTS) {
38
- if (intent.pattern.test(text)) return intent;
40
+ if (intent.pattern.test(text)) matches.push(intent);
39
41
  }
40
- return null;
42
+ if (matches.length === 0) return null;
43
+ if (matches.length === 1) return matches[0];
44
+
45
+ // Disambiguation: specifically when bugfix and recall both match, use
46
+ // position-based resolution — the pattern appearing earlier in text wins.
47
+ // "I remember we fixed..." → recall leads. "fix the bug from before" → bugfix leads.
48
+ const first = matches[0];
49
+ const second = matches[1];
50
+ if (first.type === 'bugfix' && second.useRecent) {
51
+ const bugPos = text.search(first.pattern);
52
+ const recallPos = text.search(second.pattern);
53
+ if (recallPos < bugPos) return second;
54
+ }
55
+ return first;
41
56
  }
42
57
 
43
58
  // ─── Result Dedup ───────────────────────────────────────────────────────────
@@ -65,6 +80,36 @@ export function shouldSkipByDedup(newIds, injectedFile) {
65
80
  } catch { return false; }
66
81
  }
67
82
 
83
+ // ─── Registry Skill Name Matching ───────────────────────────────────────────
84
+
85
+ /**
86
+ * Check if prompt text contains a known managed skill name.
87
+ * Returns the matched name or null.
88
+ * @param {string} text - user prompt
89
+ * @param {Set<string>} skillNames - set of known managed skill names (lowercase)
90
+ * @returns {string|null}
91
+ */
92
+ export function matchRegistrySkillName(text, skillNames) {
93
+ if (!text || skillNames.size === 0) return null;
94
+ const lower = text.toLowerCase();
95
+
96
+ // Sort names longest-first to match "code-review-expert" before "code-review"
97
+ const sorted = [...skillNames].sort((a, b) => b.length - a.length);
98
+
99
+ for (const name of sorted) {
100
+ const idx = lower.indexOf(name);
101
+ if (idx === -1) continue;
102
+
103
+ // Check word boundaries: char before and after must be non-alphanumeric (or start/end)
104
+ const before = idx === 0 ? ' ' : lower[idx - 1];
105
+ const after = idx + name.length >= lower.length ? ' ' : lower[idx + name.length];
106
+ if (/[a-z0-9]/.test(before) || /[a-z0-9]/.test(after)) continue;
107
+
108
+ return name;
109
+ }
110
+ return null;
111
+ }
112
+
68
113
  // ─── File Path Detection ─────────────────────────────────────────────────────
69
114
 
70
115
  /** Detect file paths in text */