claude-mem-lite 2.26.1 → 2.28.2

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.
@@ -3,11 +3,13 @@
3
3
  // Runs as UserPromptSubmit hook — injects relevant memories before Claude sees the prompt
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
- import { ensureDb, DB_DIR } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE } from '../utils.mjs';
8
- import { writeFileSync, readFileSync } from 'fs';
6
+ import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, isPathConfined } from '../utils.mjs';
8
+ import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
9
9
  import { join } from 'path';
10
- import { shouldSkip, detectIntent, shouldSkipByDedup, extractFiles, DEDUP_STALE_MS } from './prompt-search-utils.mjs';
10
+ import { homedir } from 'os';
11
+ import Database from 'better-sqlite3';
12
+ import { shouldSkip, detectIntent, shouldSkipByDedup, extractFiles, DEDUP_STALE_MS, matchRegistrySkillName } from './prompt-search-utils.mjs';
11
13
 
12
14
  // ─── Constants ──────────────────────────────────────────────────────────────
13
15
 
@@ -152,6 +154,92 @@ function formatResults(rows) {
152
154
  return lines.join('\n');
153
155
  }
154
156
 
157
+ // ─── Registry Skill Auto-Load ──────────────────────────────────────────────
158
+
159
+ const SKILL_COOLDOWN_FILE = join(DB_DIR, 'runtime', `.skill-cooldown-${inferProject()}`);
160
+ const SKILL_COOLDOWN_MS = 300_000; // 5 minutes
161
+ const SKILL_TOKEN_LIMIT = 16000; // ~4000 tokens
162
+
163
+ function loadManagedSkillNames() {
164
+ if (!existsSync(REGISTRY_DB_PATH)) return new Set();
165
+ try {
166
+ const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
167
+ rdb.pragma('busy_timeout = 500');
168
+ try {
169
+ const rows = rdb.prepare(`
170
+ SELECT name FROM resources
171
+ WHERE status = 'active' AND local_path LIKE '%/.claude-mem-lite/managed/%'
172
+ `).all();
173
+ return new Set(rows.map(r => r.name.toLowerCase()));
174
+ } finally { rdb.close(); }
175
+ } catch { return new Set(); }
176
+ }
177
+
178
+ function toPortablePath(absPath) {
179
+ const home = homedir();
180
+ return absPath.startsWith(home) ? '~' + absPath.slice(home.length) : absPath;
181
+ }
182
+
183
+ function loadSkillContent(skillName) {
184
+ if (!existsSync(REGISTRY_DB_PATH)) return null;
185
+ try {
186
+ const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
187
+ rdb.pragma('busy_timeout = 500');
188
+ try {
189
+ const row = rdb.prepare(`
190
+ SELECT name, type, local_path FROM resources
191
+ WHERE status = 'active'
192
+ AND (name = ? OR invocation_name = ?)
193
+ AND local_path LIKE '%/.claude-mem-lite/managed/%'
194
+ LIMIT 1
195
+ `).get(skillName, skillName);
196
+
197
+ if (!row || !row.local_path) return null;
198
+
199
+ let path = row.local_path;
200
+ if (!path.endsWith('.md')) {
201
+ const candidate = join(path, 'SKILL.md');
202
+ if (existsSync(candidate)) path = candidate;
203
+ }
204
+ // Path confinement check — prevent LIKE bypass via '../' in local_path
205
+ const managedBase = join(homedir(), '.claude-mem-lite');
206
+ if (!isPathConfined(path, managedBase)) return null;
207
+ if (!existsSync(path)) return null;
208
+
209
+ const portablePath = toPortablePath(path);
210
+ const sourceLabel = row.type === 'agent' ? 'managed-agent' : 'managed-skill';
211
+ const content = readFileSync(path, 'utf8');
212
+ if (content.length > SKILL_TOKEN_LIMIT) {
213
+ return `<skill-auto-loaded name="${row.name}" source="${sourceLabel}" path="${portablePath}" truncated="true">\n${content.slice(0, 800)}\n...\n</skill-auto-loaded>\nSkill truncated. Full content: Read("${portablePath}") or mem_use(name="${row.name}")`;
214
+ }
215
+ return `<skill-auto-loaded name="${row.name}" source="${sourceLabel}" path="${portablePath}">\n${content}\n</skill-auto-loaded>\nFollow the instructions above. Reload: Read("${portablePath}")`;
216
+ } finally { rdb.close(); }
217
+ } catch { return null; }
218
+ }
219
+
220
+ function getSkillCooldown() {
221
+ try {
222
+ const raw = readFileSync(SKILL_COOLDOWN_FILE, 'utf8');
223
+ const data = JSON.parse(raw);
224
+ const now = Date.now();
225
+ const cleaned = {};
226
+ for (const [k, v] of Object.entries(data)) {
227
+ if (now - v < SKILL_COOLDOWN_MS) cleaned[k] = v;
228
+ }
229
+ return cleaned;
230
+ } catch { return {}; }
231
+ }
232
+
233
+ function setSkillCooldown(name) {
234
+ try {
235
+ const data = getSkillCooldown();
236
+ data[name] = Date.now();
237
+ const tmp = SKILL_COOLDOWN_FILE + `.tmp-${process.pid}`;
238
+ writeFileSync(tmp, JSON.stringify(data));
239
+ renameSync(tmp, SKILL_COOLDOWN_FILE);
240
+ } catch { /* silent */ }
241
+ }
242
+
155
243
  // ─── Main ───────────────────────────────────────────────────────────────────
156
244
 
157
245
  async function main() {
@@ -209,9 +297,9 @@ async function main() {
209
297
  }
210
298
 
211
299
  const candidateIds = rows.map(r => r.id);
212
- if (shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE)) return;
300
+ const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
213
301
 
214
- const output = formatResults(rows);
302
+ const output = !dedupSkip ? formatResults(rows) : null;
215
303
  if (output) {
216
304
  process.stdout.write(output + '\n');
217
305
  // Write injected IDs for dedup with hook.mjs handleUserPrompt + self-dedup
@@ -228,6 +316,22 @@ async function main() {
228
316
  }));
229
317
  } catch {}
230
318
  }
319
+
320
+ // ─── L1: Registry skill auto-load ───────────────────────────────────
321
+ try {
322
+ const skillNames = loadManagedSkillNames();
323
+ const matched = matchRegistrySkillName(promptText, skillNames);
324
+ if (matched) {
325
+ const cooldown = getSkillCooldown();
326
+ if (!cooldown[matched]) {
327
+ const skillContent = loadSkillContent(matched);
328
+ if (skillContent) {
329
+ process.stdout.write('\n' + skillContent + '\n');
330
+ setSkillCooldown(matched);
331
+ }
332
+ }
333
+ }
334
+ } catch { /* silent — never block on registry failure */ }
231
335
  } catch {
232
336
  // Hooks must never break Claude Code — swallow all errors
233
337
  } finally {
package/server.mjs CHANGED
@@ -4,13 +4,14 @@
4
4
 
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
7
+ import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, fmtDate, isoWeekKey, debugLog, debugCatch, COMPRESSED_PENDING_PURGE, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, getCurrentBranch, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
8
8
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
9
9
  import { ensureDb, DB_PATH, REGISTRY_DB_PATH, checkFTSIntegrity, rebuildFTS } from './schema.mjs';
10
10
  import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts, autoBoostIfNeeded, runIdleCleanup } from './server-internals.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
- import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema } from './tool-schemas.mjs';
13
- import { basename } from 'path';
12
+ import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema } from './tool-schemas.mjs';
13
+ import { basename, join } from 'path';
14
+ import { homedir } from 'os';
14
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
16
  import { searchResources } from './registry-retriever.mjs';
16
17
  import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
@@ -21,7 +22,7 @@ const { version: PKG_VERSION } = require('./package.json');
21
22
 
22
23
  // ─── Database ───────────────────────────────────────────────────────────────
23
24
 
24
- import { rmSync } from 'fs';
25
+ import { rmSync, existsSync, readFileSync } from 'fs';
25
26
 
26
27
  let db;
27
28
  try {
@@ -115,6 +116,15 @@ const server = new McpServer(
115
116
  ' • Starting work on a module → recall past decisions: claude-mem-lite search "module-name" --type decision',
116
117
  ' • After solving a non-obvious problem → save the lesson: mem_save with lesson_learned',
117
118
  ' • When hook-injected context mentions a relevant ID → get details: claude-mem-lite get ID',
119
+ '',
120
+ 'Decision rules (use INSTEAD OF multi-step search):',
121
+ ' • "what happened recently?" → mem_recent (NOT search with empty query)',
122
+ ' • "what do we know about file.mjs?" → mem_recall (NOT grep + manual search)',
123
+ ' • "show me around observation #42" → mem_timeline (NOT mem_get + manual navigation)',
124
+ ' • "clean up old/duplicate memories" → mem_maintain (NOT manual mem_delete loop)',
125
+ ' • "is the search index healthy?" → mem_fts_check (NOT manual COUNT queries)',
126
+ ' • "overview of memory tiers" → mem_browse (NOT mem_search + manual grouping)',
127
+ ' • "export for backup" → mem_export (NOT manual SELECT queries)',
118
128
  ].join('\n'),
119
129
  },
120
130
  );
@@ -514,7 +524,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
514
524
  server.registerTool(
515
525
  'mem_search',
516
526
  {
517
- description: 'FTS5 full-text search across observations, sessions, and prompts with BM25 ranking. Returns compact index (use mem_get for details).',
527
+ description: 'Search project memory for past bugfixes, decisions, and discoveries. Use when: encountering a familiar error, investigating a module before changes, or looking for prior art on a problem. Returns compact index (use mem_get for full details).',
518
528
  inputSchema: memSearchSchema,
519
529
  },
520
530
  safeHandler(async (args) => {
@@ -650,7 +660,7 @@ server.registerTool(
650
660
  server.registerTool(
651
661
  'mem_recent',
652
662
  {
653
- description: 'Show most recent observations, ordered by time. Quick way to see latest activity without a search query.',
663
+ description: 'Show most recent observations. Use when: checking what happened recently in the project, reviewing progress after being away, or verifying that a recent change was captured.',
654
664
  inputSchema: memRecentSchema,
655
665
  },
656
666
  safeHandler(async (args) => {
@@ -689,7 +699,7 @@ server.registerTool(
689
699
  server.registerTool(
690
700
  'mem_timeline',
691
701
  {
692
- description: 'Browse observations as a timeline around an anchor point. Use query to auto-find anchor, or specify anchor ID directly.',
702
+ description: 'Browse observations as a timeline around an anchor point. Use when: exploring what happened before/after a specific observation, understanding the sequence of changes that led to a bug, or reviewing a session chronologically.',
693
703
  inputSchema: memTimelineSchema,
694
704
  },
695
705
  safeHandler(async (args) => {
@@ -748,7 +758,9 @@ server.registerTool(
748
758
  }
749
759
 
750
760
  // Update access_count for anchor (aligned with CLI timeline)
751
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
761
+ try {
762
+ db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
763
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
752
764
 
753
765
  const projectFilter = args.project ? 'AND project = ?' : '';
754
766
  const baseParams = args.project ? [args.project] : [];
@@ -790,7 +802,7 @@ server.registerTool(
790
802
  server.registerTool(
791
803
  'mem_get',
792
804
  {
793
- description: 'Get full details for one or more records by ID. Use after mem_search to drill into specific records.',
805
+ description: 'Get full details for one or more records by ID. Use when: hook-injected context mentions a relevant observation ID, or after mem_search to drill into specific results for narrative, lesson_learned, and file details.',
794
806
  inputSchema: memGetSchema,
795
807
  },
796
808
  safeHandler(async (args) => {
@@ -808,11 +820,12 @@ server.registerTool(
808
820
  prefix = 'P#';
809
821
  } else {
810
822
  // Increment access_count for retrieved observations (batch UPDATE)
811
- db.prepare(
812
- `UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
813
- ).run(Date.now(), ...args.ids);
814
- // Auto-boost importance for frequently accessed observations
815
- autoBoostIfNeeded(db, args.ids);
823
+ try {
824
+ db.prepare(
825
+ `UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`
826
+ ).run(Date.now(), ...args.ids);
827
+ autoBoostIfNeeded(db, args.ids);
828
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
816
829
  rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...args.ids);
817
830
  allFields = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
818
831
  prefix = '#';
@@ -848,7 +861,7 @@ server.registerTool(
848
861
  server.registerTool(
849
862
  'mem_delete',
850
863
  {
851
- description: 'Delete observations by ID. Use confirm=false to preview, confirm=true to execute. FTS5 cleanup is automatic via triggers.',
864
+ description: 'Delete observations by ID. Use when: cleaning up incorrect or duplicate observations, removing test data, or when the user asks to forget something. Use confirm=false to preview, confirm=true to execute.',
852
865
  inputSchema: memDeleteSchema,
853
866
  },
854
867
  safeHandler(async (args) => {
@@ -912,7 +925,7 @@ server.registerTool(
912
925
  server.registerTool(
913
926
  'mem_save',
914
927
  {
915
- description: 'Manually save a memory/observation. Use for important findings, decisions, or notes worth preserving.',
928
+ description: 'Save a memory/observation. Use when: solving a non-obvious bug (save the lesson), making an architecture decision, discovering something not obvious from code alone, or when the user asks to remember something.',
916
929
  inputSchema: memSaveSchema,
917
930
  },
918
931
  safeHandler(async (args) => {
@@ -994,7 +1007,7 @@ server.registerTool(
994
1007
  server.registerTool(
995
1008
  'mem_stats',
996
1009
  {
997
- description: 'Get statistics about stored memories: counts, types, projects, recent activity.',
1010
+ description: 'Get memory statistics: counts, types, projects, daily activity, data health. Use when: assessing memory system health, checking how much project history exists, or diagnosing search quality issues.',
998
1011
  inputSchema: memStatsSchema,
999
1012
  },
1000
1013
  safeHandler(async (args) => {
@@ -1104,7 +1117,7 @@ server.registerTool(
1104
1117
  server.registerTool(
1105
1118
  'mem_compress',
1106
1119
  {
1107
- description: 'Compress old low-value observations into weekly summaries. Use preview=true to see candidates first.',
1120
+ description: 'Compress old low-value observations into weekly summaries. Use when: memory database is growing large, observations are months old, or after a major project phase completes. Use preview=true to see candidates first.',
1108
1121
  inputSchema: memCompressSchema,
1109
1122
  },
1110
1123
  safeHandler(async (args) => {
@@ -1217,7 +1230,7 @@ server.registerTool(
1217
1230
  server.registerTool(
1218
1231
  'mem_maintain',
1219
1232
  {
1220
- description: 'Memory maintenance: scan for duplicates/stale/broken items, then execute cleanup/decay/boost/dedup operations.',
1233
+ description: 'Memory maintenance: scan for duplicates/stale/broken items, then execute cleanup/decay/boost/dedup operations. Use when: search results seem noisy with duplicates, after bulk imports, or during periodic maintenance.',
1221
1234
  inputSchema: memMaintainSchema,
1222
1235
  },
1223
1236
  safeHandler(async (args) => {
@@ -1478,7 +1491,7 @@ server.registerTool(
1478
1491
  server.registerTool(
1479
1492
  'mem_registry',
1480
1493
  {
1481
- description: 'Manage tool resource registry: search for skills/agents by need, list resources, view stats, import/remove tools, reindex FTS5.',
1494
+ description: 'Manage tool resource registry. Use when: looking for a skill or agent to solve a problem, importing tools from a repository, checking what resources are available, or managing installed tools.',
1482
1495
  inputSchema: memRegistrySchema,
1483
1496
  },
1484
1497
  safeHandler(async (args) => {
@@ -1511,13 +1524,30 @@ server.registerTool(
1511
1524
  if (results.length === 0) {
1512
1525
  return { content: [{ type: 'text', text: `No matching resources for: "${args.query}"` }] };
1513
1526
  }
1527
+ const home = homedir();
1528
+ const toPortable = (p) => p && p.startsWith(home) ? '~' + p.slice(home.length) : (p || '');
1514
1529
  const lines = results.map(r => {
1515
1530
  const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
1516
1531
  const categoryLabel = r.category ? ` [${r.category}]` : '';
1517
- const howToUse = r.type === 'skill'
1518
- ? (r.invocation_name ? `Skill tool: skill="${r.invocation_name}"` : `Community skill: ${r.name}`)
1519
- : `Agent tool: subagent_type="${r.invocation_name || r.name}"`;
1520
- return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}\n Use: ${howToUse}`;
1532
+ const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
1533
+ const portablePath = isManaged ? toPortable(r.local_path) : '';
1534
+ let howToUse;
1535
+ if (isManaged) {
1536
+ // Managed: use Read(path) or mem_use — Skill() won't work for managed resources
1537
+ // Agents always have complete .md paths (e.g., agents/group/agents/name.md)
1538
+ // Only skills can be directory paths (9 cases) — resolve to /SKILL.md
1539
+ const resolvedPath = portablePath.endsWith('.md') ? portablePath : `${portablePath}/SKILL.md`;
1540
+ howToUse = `Read("${resolvedPath}") or mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
1541
+ } else if (r.invocation_name) {
1542
+ // Native plugin/user skill: Skill() with full invocation name
1543
+ howToUse = r.type === 'skill'
1544
+ ? `Skill("${r.invocation_name}")`
1545
+ : `Agent(subagent_type="${r.invocation_name}")`;
1546
+ } else {
1547
+ howToUse = `mem_use(name="${r.name}"${r.type === 'agent' ? ', type="agent"' : ''})`;
1548
+ }
1549
+ const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
1550
+ return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`;
1521
1551
  });
1522
1552
  return { content: [{ type: 'text', text: `Found ${results.length} resource(s) for "${args.query}":\n\n${lines.join('\n\n')}` }] };
1523
1553
  }
@@ -1597,16 +1627,160 @@ server.registerTool(
1597
1627
  return { content: [{ type: 'text', text: `FTS5 reindexed. ${count.c} active resources.` }] };
1598
1628
  }
1599
1629
 
1600
- return { content: [{ type: 'text', text: `Unknown action: ${action}. Valid: search, list, stats, import, remove, reindex` }], isError: true };
1630
+ if (action === 'import_url') {
1631
+ if (!args.url) {
1632
+ return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
1633
+ }
1634
+ const { importFromGitHub } = await import('./registry-importer.mjs');
1635
+ try {
1636
+ const results = await importFromGitHub(rdb, args.url);
1637
+ if (results.length === 0) {
1638
+ return { content: [{ type: 'text', text: `No skills/agents found in: ${args.url}` }] };
1639
+ }
1640
+
1641
+ let enrichMsg = '';
1642
+ if (args.enrich) {
1643
+ const { enrichResource } = await import('./registry-enricher.mjs');
1644
+ let ok = 0;
1645
+ for (const r of results) {
1646
+ const row = rdb.prepare('SELECT local_path FROM resources WHERE id = ?').get(r.id);
1647
+ if (!row?.local_path) continue;
1648
+ try {
1649
+ const content = readFileSync(row.local_path, 'utf8');
1650
+ if (await enrichResource(rdb, r.name, r.type, content)) ok++;
1651
+ } catch {}
1652
+ }
1653
+ enrichMsg = `\nEnriched: ${ok}/${results.length}`;
1654
+ }
1655
+
1656
+ const lines = results.map(r => `${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
1657
+ return { content: [{ type: 'text', text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}` }] };
1658
+ } catch (e) {
1659
+ return { content: [{ type: 'text', text: `Import failed: ${e.message}` }], isError: true };
1660
+ }
1661
+ }
1662
+
1663
+ if (action === 'enrich') {
1664
+ if (!args.name) {
1665
+ return { content: [{ type: 'text', text: 'enrich requires a name parameter' }], isError: true };
1666
+ }
1667
+ const row = rdb.prepare("SELECT name, type, local_path FROM resources WHERE name = ? AND status = 'active'").get(args.name);
1668
+ if (!row) {
1669
+ return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
1670
+ }
1671
+ if (!row.local_path) {
1672
+ return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1673
+ }
1674
+ const enrichBase = join(homedir(), '.claude-mem-lite');
1675
+ if (!isPathConfined(row.local_path, enrichBase)) {
1676
+ return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1677
+ }
1678
+
1679
+ const { enrichResource } = await import('./registry-enricher.mjs');
1680
+ try {
1681
+ const content = readFileSync(row.local_path, 'utf8');
1682
+ const ok = await enrichResource(rdb, row.name, row.type, content);
1683
+ return { content: [{ type: 'text', text: ok ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}` }] };
1684
+ } catch (e) {
1685
+ return { content: [{ type: 'text', text: `Enrich error: ${e.message}` }], isError: true };
1686
+ }
1687
+ }
1688
+
1689
+ return { content: [{ type: 'text', text: `Unknown action: ${action}. Valid: search, list, stats, import, remove, reindex, import_url, enrich` }], isError: true };
1601
1690
  })
1602
1691
  );
1603
1692
 
1693
+ // ─── Tool: mem_use ──────────────────────────────────────────────────────────
1694
+
1695
+ server.registerTool(
1696
+ 'mem_use',
1697
+ {
1698
+ description: 'Load and activate a skill or agent from the managed registry. '
1699
+ + 'Returns the full skill/agent content for execution. '
1700
+ + 'Use when: you found a skill via mem_registry search and want to use it, '
1701
+ + 'or you know a managed skill/agent name and want to load its instructions.',
1702
+ inputSchema: memUseSchema,
1703
+ },
1704
+ safeHandler(async (args) => {
1705
+ const rdb = getRegistryDb();
1706
+ if (!rdb) {
1707
+ return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
1708
+ }
1709
+
1710
+ const name = args.name.trim();
1711
+ const type = args.type || 'skill';
1712
+
1713
+ // 1. Exact match by name or invocation_name
1714
+ let row = rdb.prepare(`
1715
+ SELECT id, name, type, local_path, invocation_name, capability_summary
1716
+ FROM resources
1717
+ WHERE status = 'active' AND type = ?
1718
+ AND (name = ? OR invocation_name = ?)
1719
+ LIMIT 1
1720
+ `).get(type, name, name);
1721
+
1722
+ // 2. Fuzzy fallback: FTS5 search, take top result
1723
+ if (!row) {
1724
+ const results = searchResources(rdb, name, { type, limit: 1 });
1725
+ if (results.length > 0) {
1726
+ row = rdb.prepare(`SELECT id, name, type, local_path, invocation_name, capability_summary FROM resources WHERE name = ? AND type = ? AND status = 'active'`).get(results[0].name, results[0].type);
1727
+ }
1728
+ }
1729
+
1730
+ if (!row) {
1731
+ return { content: [{ type: 'text', text: `No ${type} found for "${name}". Try mem_registry(action="search", query="${name}") to browse.` }] };
1732
+ }
1733
+
1734
+ // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
1735
+ let skillPath = row.local_path || '';
1736
+ if (skillPath && !skillPath.endsWith('.md')) {
1737
+ for (const candidate of [
1738
+ join(skillPath, 'SKILL.md'),
1739
+ join(skillPath, `skills/${row.name}/SKILL.md`),
1740
+ ]) {
1741
+ if (existsSync(candidate)) { skillPath = candidate; break; }
1742
+ }
1743
+ }
1744
+
1745
+ // 4. Path confinement check — prevent reading arbitrary files via crafted local_path
1746
+ const managedBase = join(homedir(), '.claude-mem-lite');
1747
+ if (skillPath && !isPathConfined(skillPath, managedBase)) {
1748
+ return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
1749
+ }
1750
+
1751
+ // 5. Read content
1752
+ let content;
1753
+ try {
1754
+ content = readFileSync(skillPath, 'utf8');
1755
+ } catch {
1756
+ const msg = skillPath.endsWith('.md')
1757
+ ? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
1758
+ : `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
1759
+ return { content: [{ type: 'text', text: msg }], isError: true };
1760
+ }
1761
+
1762
+ // 5. Record invocation
1763
+ try {
1764
+ rdb.prepare(`
1765
+ INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
1766
+ VALUES (?, ?, 'user_explicit', 1, 'success')
1767
+ `).run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
1768
+ } catch { /* non-critical */ }
1769
+
1770
+ const _home = homedir();
1771
+ const portablePath = skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : (skillPath || '');
1772
+ const pathAttr = portablePath ? ` path="${portablePath}"` : '';
1773
+ const reloadHint = portablePath ? ` Reload: Read("${portablePath}")` : '';
1774
+ return { content: [{ type: 'text', text: `<skill-loaded name="${row.name}" type="${row.type}"${pathAttr}>\n${content}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}` }] };
1775
+ }),
1776
+ );
1777
+
1604
1778
  // ─── Tool: mem_update ────────────────────────────────────────────────────────
1605
1779
 
1606
1780
  server.registerTool(
1607
1781
  'mem_update',
1608
1782
  {
1609
- description: 'Update an existing observation in-place. Preserves original ID and references.',
1783
+ description: 'Update an existing observation in-place. Use when: an observation needs correction, additional context was discovered later, or the user asks to update a specific memory. Preserves original ID and references.',
1610
1784
  inputSchema: memUpdateSchema,
1611
1785
  },
1612
1786
  safeHandler(async (args) => {
@@ -1658,7 +1832,7 @@ server.registerTool(
1658
1832
  server.registerTool(
1659
1833
  'mem_export',
1660
1834
  {
1661
- description: 'Export observations as JSON or JSONL for backup or migration.',
1835
+ description: 'Export observations as JSON or JSONL. Use when: backing up memory before migration, sharing observations between machines, or creating a snapshot before major changes.',
1662
1836
  inputSchema: memExportSchema,
1663
1837
  },
1664
1838
  safeHandler(async (args) => {
@@ -1698,7 +1872,7 @@ server.registerTool(
1698
1872
  server.registerTool(
1699
1873
  'mem_recall',
1700
1874
  {
1701
- description: 'Recall observations related to a file. Use before editing a file to recall past bugfixes, decisions, and context.',
1875
+ description: 'Recall observations related to a file. Use when: about to edit a file, investigating a file with past issues, or before refactoring to recall past bugfixes, decisions, and context.',
1702
1876
  inputSchema: memRecallSchema,
1703
1877
  },
1704
1878
  safeHandler(async (args) => {
@@ -1724,7 +1898,9 @@ server.registerTool(
1724
1898
  // Update access_count for recalled observations
1725
1899
  const recalledIds = rows.map(r => r.id);
1726
1900
  const ph = recalledIds.map(() => '?').join(',');
1727
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1901
+ try {
1902
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1903
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
1728
1904
 
1729
1905
  const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
1730
1906
  for (const r of rows) {
@@ -1741,7 +1917,7 @@ server.registerTool(
1741
1917
  server.registerTool(
1742
1918
  'mem_fts_check',
1743
1919
  {
1744
- description: 'Check FTS5 index integrity or rebuild indexes. Use when search results seem wrong or after database recovery.',
1920
+ description: 'Check FTS5 index integrity or rebuild indexes. Use when: search results seem wrong or missing, after database recovery, or after manual DB edits.',
1745
1921
  inputSchema: memFtsCheckSchema,
1746
1922
  },
1747
1923
  safeHandler(async (args) => {
@@ -1767,7 +1943,7 @@ server.registerTool(
1767
1943
  server.registerTool(
1768
1944
  'mem_browse',
1769
1945
  {
1770
- description: 'Tier-grouped memory dashboard. Shows observations organized by memory tier (working/active/archive).',
1946
+ description: 'Tier-grouped memory dashboard. Use when: getting an overview of memory health, seeing how observations are distributed across tiers, or assessing what to compress or clean up.',
1771
1947
  inputSchema: memBrowseSchema,
1772
1948
  },
1773
1949
  safeHandler(async (args) => {
package/tfidf.mjs CHANGED
@@ -381,7 +381,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
381
381
 
382
382
  // Fallback: if time-window yields too few, scan without time constraint
383
383
  if (rows.length < VECTOR_MIN_RESULTS) {
384
- params.push(limit);
384
+ const fallbackParams = [...params, limit];
385
385
  rows = db.prepare(`
386
386
  SELECT ov.observation_id, ov.vector
387
387
  FROM observation_vectors ov
@@ -389,7 +389,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
389
389
  WHERE ${wheres.join(' AND ')}
390
390
  ORDER BY o.created_at_epoch DESC
391
391
  LIMIT ?
392
- `).all(...params);
392
+ `).all(...fallbackParams);
393
393
  }
394
394
 
395
395
  const results = [];
package/tool-schemas.mjs CHANGED
@@ -130,12 +130,12 @@ export const memFtsCheckSchema = {
130
130
  };
131
131
 
132
132
  export const memRegistrySchema = {
133
- action: z.enum(['list', 'stats', 'search', 'import', 'remove', 'reindex']).describe('Registry operation'),
133
+ action: z.enum(['list', 'stats', 'search', 'import', 'remove', 'reindex', 'import_url', 'enrich']).describe('Registry operation'),
134
134
  query: z.string().optional().describe('Search query — keywords describing what you need (for search)'),
135
135
  type: z.enum(['skill', 'agent']).optional().describe('Filter by resource type (for list/search)'),
136
136
  name: z.string().optional().describe('Resource name (for import/remove)'),
137
137
  resource_type: z.enum(['skill', 'agent']).optional().describe('Resource type (for import/remove)'),
138
- source: z.enum(['preinstalled', 'user']).optional().describe('Source (for import, default: user)'),
138
+ source: z.enum(['preinstalled', 'user', 'github']).optional().describe('Source (for import, default: user)'),
139
139
  repo_url: z.string().optional().describe('GitHub repository URL (for import)'),
140
140
  local_path: z.string().optional().describe('Local file path (for import)'),
141
141
  invocation_name: z.string().optional().describe('Invocation name like "plugin:skill" (for import)'),
@@ -146,10 +146,17 @@ export const memRegistrySchema = {
146
146
  keywords: z.string().optional().describe('Search keywords (for import)'),
147
147
  tech_stack: z.string().optional().describe('Technology stack tags (for import)'),
148
148
  use_cases: z.string().optional().describe('Usage scenarios (for import)'),
149
+ url: z.string().optional().describe('GitHub repository URL (for import_url action)'),
150
+ enrich: coerceBool.optional().describe('Auto-enrich imported resources (for import_url action)'),
149
151
  category: z.string().optional().describe("Filter by category (e.g., 'testing', 'code-quality', 'debugging')"),
150
152
  quality: z.enum(['installed', 'verified', 'community']).optional().describe('Filter by quality tier (default: all)'),
151
153
  };
152
154
 
155
+ export const memUseSchema = {
156
+ name: z.string().min(1).describe('Skill or agent name to load (exact name or search query)'),
157
+ type: z.enum(['skill', 'agent']).optional().describe('Resource type (default: skill)'),
158
+ };
159
+
153
160
  export const memBrowseSchema = {
154
161
  project: z.string().optional().describe('Filter by project (default: inferred from CWD)'),
155
162
  tier: z.enum(['working', 'active', 'archive']).optional().describe('Show only this tier'),
package/utils.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Used by server.mjs, hook.mjs, and tests
3
3
 
4
4
 
5
- import { basename, dirname } from 'path';
5
+ import { basename, dirname, resolve, sep } from 'path';
6
6
  import { execSync } from 'child_process';
7
7
 
8
8
  // ─── Re-exports from extracted modules ──────────────────────────────────────
@@ -27,6 +27,21 @@ export const COMPRESSED_AUTO = -1;
27
27
  /** compressed_into sentinel: pending user-confirmed purge (marked by idle cleanup) */
28
28
  export const COMPRESSED_PENDING_PURGE = -2;
29
29
 
30
+ // ─── Path Safety ──────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Check if a resolved path is confined within an allowed base directory.
34
+ * Prevents path traversal attacks via '../' sequences.
35
+ * @param {string} candidate Path to check
36
+ * @param {string} allowedBase Base directory the path must stay within
37
+ * @returns {boolean} true if safe
38
+ */
39
+ export function isPathConfined(candidate, allowedBase) {
40
+ const resolved = resolve(candidate);
41
+ const base = resolve(allowedBase);
42
+ return resolved === base || resolved.startsWith(base + sep);
43
+ }
44
+
30
45
  // ─── Token Estimation ─────────────────────────────────────────────────────
31
46
 
32
47
  /**
@@ -157,9 +172,11 @@ export function isRelatedToEpisode(episode, newFiles) {
157
172
  * @param {string} toolName Name of the tool (Edit, Write, Bash, etc.)
158
173
  * @param {object} input Tool input parameters
159
174
  * @param {string} resp Tool response text
175
+ * @param {object} [opts] Optional signals from detectBashSignificance
176
+ * @param {boolean} [opts.isError] If provided, overrides inline error regex detection
160
177
  * @returns {string} Concise description of the action
161
178
  */
162
- export function makeEntryDesc(toolName, input, resp) {
179
+ export function makeEntryDesc(toolName, input, resp, opts) {
163
180
  switch (toolName) {
164
181
  case 'Edit':
165
182
  return `${basename(input.file_path || '')}: "${truncate(input.old_string || '', 40)}" → "${truncate(input.new_string || '', 40)}"`;
@@ -169,7 +186,9 @@ export function makeEntryDesc(toolName, input, resp) {
169
186
  return `Notebook cell: ${truncate(input.new_source || '', 60)}`;
170
187
  case 'Bash': {
171
188
  const cmd = truncate(input.command || '', 50);
172
- const isErr = /error|fail|exception|panic/i.test(resp) && resp.length > 30;
189
+ // Use caller-provided bashSig.isError (word-boundary aware) when available;
190
+ // fall back to inline regex only for standalone callers (tests, etc.)
191
+ const isErr = opts?.isError ?? (/\berror\b|\bfail(ed|ure)?\b|\bexception\b|\bpanic\b/i.test(resp) && resp.length > 30);
173
192
  const snippet = truncate(resp, 60);
174
193
  return isErr ? `${cmd} → ERROR: ${snippet}` : `${cmd} → ${snippet}`;
175
194
  }