claude-mem-lite 2.2.3 → 2.3.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,51 @@
1
+ ---
2
+ name: memory
3
+ description: Save content to memory — with explicit content, instructions, or auto-summarize current session
4
+ ---
5
+
6
+ # Memory Save
7
+
8
+ Save important content to your long-term memory database.
9
+
10
+ ## Commands
11
+
12
+ - `/mem:memory <content>` — Save the given content directly to memory
13
+ - `/mem:memory` (no args) — Auto-summarize recent session highlights and save key findings
14
+
15
+ ## Instructions
16
+
17
+ When the user invokes `/mem:memory`, determine the intent:
18
+
19
+ ### With explicit content
20
+
21
+ If the user provides content after the command:
22
+
23
+ 1. Analyze the content to determine appropriate type (decision, bugfix, feature, refactor, discovery, change)
24
+ 2. Generate a concise title from the content
25
+ 3. Call `mem_save` with:
26
+ - `content`: the provided text
27
+ - `title`: auto-generated title
28
+ - `type`: inferred type (default: "discovery")
29
+ - `importance`: 2 (notable — user explicitly requested save)
30
+
31
+ ### With instructions/prompt
32
+
33
+ If the user provides instructions like "save the database schema we discussed" or "remember the fix for the auth bug":
34
+
35
+ 1. Review recent conversation context
36
+ 2. Extract the relevant information per the user's instruction
37
+ 3. Call `mem_save` with extracted content, appropriate title and type, importance=2
38
+
39
+ ### No arguments (auto-save)
40
+
41
+ If no content is provided:
42
+
43
+ 1. Review the current session's recent key findings
44
+ 2. Identify: decisions made, bugs fixed, patterns discovered, important code changes
45
+ 3. For each significant finding (max 5), call `mem_save` with:
46
+ - Clear title and structured content
47
+ - Appropriate type and importance level (1=routine, 2=notable)
48
+ 4. Skip trivial or already-saved items
49
+ 5. Report what was saved in a concise summary
50
+
51
+ Always set importance=2 for explicit saves (user chose to save), importance=1 for auto-saves of routine items, importance=2 for auto-saves of notable discoveries.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: tools
3
+ description: Import skills and agents from GitHub repositories into the tool resource registry
4
+ ---
5
+
6
+ # Tool Import
7
+
8
+ Import skills and agents from GitHub repositories into the resource registry for intelligent dispatch.
9
+
10
+ ## Commands
11
+
12
+ - `/mem:tools <github-url>` — Import all skills/agents from a GitHub repo
13
+ - `/mem:tools <github-url> <instructions>` — Import with specific instructions (add/remove specific items)
14
+ - `/mem:tools <instructions>` — Directly add/remove/modify tools by prompt (no URL needed)
15
+ - `/mem:tools` (no args) — Show current registry stats and import help
16
+
17
+ ## Instructions
18
+
19
+ When the user invokes `/mem:tools`:
20
+
21
+ ### With GitHub URL
22
+
23
+ 1. Use WebFetch to fetch the repository README and skill/agent files:
24
+ - Try `https://raw.githubusercontent.com/{owner}/{repo}/main/README.md`
25
+ - Look for skill definitions (`.md` files with frontmatter), agent definitions, plugin.json
26
+ - If the repo has a `commands/` directory, fetch skill files from there
27
+ 2. Identify all skills and agents in the repository
28
+ 3. For each tool found, extract metadata using your understanding of the content:
29
+ - `name`: tool name (lowercase, hyphenated)
30
+ - `resource_type`: "skill" or "agent"
31
+ - `repo_url`: the GitHub URL
32
+ - `intent_tags`: comma-separated intent keywords (what the tool helps with)
33
+ - `domain_tags`: comma-separated technology/domain tags
34
+ - `capability_summary`: one-line description of what the tool does
35
+ - `trigger_patterns`: when to recommend this tool (natural language)
36
+ - `keywords`: additional search terms
37
+ - `tech_stack`: technology stack tags
38
+ - `use_cases`: usage scenarios
39
+ 4. Call `mem_registry(action="import", ...)` for each tool with extracted metadata
40
+ 5. Call `mem_registry(action="reindex")` to update FTS5 index
41
+ 6. Report imported tools in a table format
42
+
43
+ ### With GitHub URL + instructions
44
+
45
+ If the user provides instructions after the URL:
46
+ - "only add the TDD skill" → import only matching tools from that repo
47
+ - "remove the old testing tool" → call `mem_registry(action="remove", ...)`
48
+ - Follow user instructions for selective add/remove/modify operations
49
+
50
+ ### With instructions only (no URL)
51
+
52
+ If the user provides a prompt without a GitHub URL, parse the intent:
53
+
54
+ **Adding a tool:**
55
+ - "添加一个叫 my-linter 的 skill" or "add a skill called my-linter"
56
+ - → Ask for metadata (or infer from context): capability_summary, intent_tags, domain_tags, trigger_patterns
57
+ - → Call `mem_registry(action="import", name="my-linter", resource_type="skill", ...)`
58
+
59
+ **Removing a tool:**
60
+ - "删除 old-testing skill" or "remove the old-testing agent"
61
+ - → Call `mem_registry(action="remove", name="old-testing", resource_type="skill")`
62
+
63
+ **Listing/searching:**
64
+ - "有哪些 testing 相关的工具" or "list all agents"
65
+ - → Call `mem_registry(action="list", type="agent")` or search by keywords
66
+
67
+ **Modifying a tool:**
68
+ - "更新 my-linter 的描述" or "update tags for my-tool"
69
+ - → Call `mem_registry(action="import", ...)` with updated metadata (upsert)
70
+
71
+ Always call `mem_registry(action="reindex")` after any add/remove/modify operations.
72
+
73
+ ### Without URL or instructions
74
+
75
+ If no arguments provided:
76
+ 1. Call `mem_registry(action="stats")` to show current registry state
77
+ 2. Call `mem_registry(action="list")` to show all registered tools
78
+ 3. Explain usage examples
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: update
3
+ description: Auto-maintain memory and resource registry — deduplicate, merge, decay, cleanup, reindex
4
+ ---
5
+
6
+ # Memory & Registry Maintenance
7
+
8
+ Run intelligent maintenance on both the memory database and tool resource registry.
9
+
10
+ ## Usage
11
+
12
+ - `/mem:update` — Run full maintenance cycle
13
+
14
+ ## Instructions
15
+
16
+ When the user invokes `/mem:update`, perform the following maintenance cycle:
17
+
18
+ ### Phase 1: Memory Maintenance
19
+
20
+ 1. Call `mem_maintain(action="scan")` to analyze maintenance candidates
21
+ 2. Report scan results to the user (duplicates, stale items, broken items, boostable items)
22
+ 3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
23
+ 4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
24
+ 5. Run `mem_compress(preview=false)` for old low-value observations
25
+
26
+ ### Phase 2: Registry Maintenance
27
+
28
+ 1. Call `mem_registry(action="stats")` to get registry overview
29
+ 2. Call `mem_registry(action="reindex")` to rebuild FTS5 search index
30
+ 3. Report updated stats
31
+
32
+ ### Phase 3: Summary
33
+
34
+ Summarize all maintenance actions taken in zh-CN:
35
+ - Memory: observations cleaned, decayed, boosted, deduplicated, compressed
36
+ - Registry: total resources, adoption rates, reindex status
37
+ - Overall health assessment
@@ -0,0 +1,155 @@
1
+ // claude-mem-lite: Workflow-aware dispatch intelligence
2
+ // Suite auto-flow protection, explicit request detection, stage model
3
+
4
+ // ─── Stage Model ─────────────────────────────────────────────────────────────
5
+
6
+ export const STAGES = ['ANALYZE', 'PLAN', 'REVIEW_PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE', 'COMMIT'];
7
+
8
+ // Skill invocation name → workflow stage
9
+ const SKILL_STAGE_MAP = {
10
+ 'superpowers:brainstorming': 'ANALYZE',
11
+ 'superpowers:writing-plans': 'PLAN',
12
+ 'gsd:start': 'PLAN',
13
+ 'gsd:prd': 'PLAN',
14
+ 'superpowers:executing-plans': 'EXECUTE',
15
+ 'superpowers:subagent-driven-development': 'EXECUTE',
16
+ 'gsd:resume': 'EXECUTE',
17
+ 'superpowers:test-driven-development': 'TEST',
18
+ 'superpowers:systematic-debugging': 'EXECUTE',
19
+ 'superpowers:verification-before-completion': 'TEST',
20
+ 'superpowers:requesting-code-review': 'REVIEW_CODE',
21
+ 'superpowers:receiving-code-review': 'REVIEW_CODE',
22
+ 'superpowers:finishing-a-development-branch': 'COMMIT',
23
+ 'commit-commands:commit': 'COMMIT',
24
+ 'commit-commands:commit-push-pr': 'COMMIT',
25
+ 'commit-commands:clean_gone': 'COMMIT',
26
+ };
27
+
28
+ // User intent → stage mapping (for stage inference from prompt)
29
+ export const INTENT_STAGE_MAP = {
30
+ 'plan': 'PLAN',
31
+ 'review': 'REVIEW_CODE',
32
+ 'test': 'TEST',
33
+ 'fix': 'EXECUTE',
34
+ 'clean': 'EXECUTE',
35
+ 'commit': 'COMMIT',
36
+ 'deploy': 'COMMIT',
37
+ 'design': 'ANALYZE',
38
+ 'doc': 'COMMIT',
39
+ 'build': 'EXECUTE',
40
+ 'fast': 'EXECUTE',
41
+ 'lint': 'EXECUTE',
42
+ 'db': 'EXECUTE',
43
+ 'api': 'EXECUTE',
44
+ 'secure': 'EXECUTE',
45
+ 'infra': 'EXECUTE',
46
+ };
47
+
48
+ // ─── Suite Auto-Flow Protection ──────────────────────────────────────────────
49
+
50
+ export const SUITE_AUTO_FLOWS = {
51
+ superpowers: {
52
+ stages: ['ANALYZE', 'PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE', 'COMMIT'],
53
+ gaps: ['REVIEW_PLAN'],
54
+ },
55
+ gsd: {
56
+ stages: ['PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE'],
57
+ gaps: ['ANALYZE', 'REVIEW_PLAN', 'COMMIT'],
58
+ },
59
+ 'feature-dev': {
60
+ stages: ['ANALYZE', 'EXECUTE', 'REVIEW_CODE'],
61
+ gaps: ['PLAN', 'REVIEW_PLAN', 'TEST', 'COMMIT'],
62
+ },
63
+ 'commit-commands': {
64
+ stages: ['COMMIT'],
65
+ gaps: ['ANALYZE', 'PLAN', 'REVIEW_PLAN', 'EXECUTE', 'TEST', 'REVIEW_CODE'],
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Detect if a suite auto-flow is active based on recent Skill tool events.
71
+ * Scans backwards to find the most recent Skill invocation from a known suite.
72
+ * @param {object[]} sessionEvents Array of tool events
73
+ * @returns {{suite: string, flow: object, lastSkill: string}|null}
74
+ */
75
+ export function detectActiveSuite(sessionEvents) {
76
+ if (!sessionEvents || sessionEvents.length === 0) return null;
77
+
78
+ for (let i = sessionEvents.length - 1; i >= 0; i--) {
79
+ const e = sessionEvents[i];
80
+ if (e.tool_name === 'Skill' && e.tool_input?.skill) {
81
+ const skill = e.tool_input.skill;
82
+ const suite = skill.split(':')[0];
83
+ if (SUITE_AUTO_FLOWS[suite]) {
84
+ return { suite, flow: SUITE_AUTO_FLOWS[suite], lastSkill: skill };
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Determine if a recommendation should be made for the current stage.
93
+ * @param {{suite: string, flow: object}|null} activeSuite Active suite info
94
+ * @param {string} currentStage Current workflow stage
95
+ * @returns {{shouldRecommend: boolean, reason: string}}
96
+ */
97
+ export function shouldRecommendForStage(activeSuite, currentStage) {
98
+ if (!activeSuite) return { shouldRecommend: true, reason: 'no_suite' };
99
+
100
+ const { flow } = activeSuite;
101
+ if (flow.stages.includes(currentStage)) {
102
+ return { shouldRecommend: false, reason: 'suite_covers_stage' };
103
+ }
104
+ if (flow.gaps.includes(currentStage)) {
105
+ return { shouldRecommend: true, reason: 'suite_gap' };
106
+ }
107
+ return { shouldRecommend: true, reason: 'unknown_stage' };
108
+ }
109
+
110
+ /**
111
+ * Infer the current workflow stage from intent or from the last skill used.
112
+ * @param {string} primaryIntent Primary intent from signal extraction
113
+ * @param {{lastSkill: string}|null} activeSuite Active suite info
114
+ * @returns {string|null} Stage name or null
115
+ */
116
+ export function inferCurrentStage(primaryIntent, activeSuite) {
117
+ if (activeSuite?.lastSkill && SKILL_STAGE_MAP[activeSuite.lastSkill]) {
118
+ return SKILL_STAGE_MAP[activeSuite.lastSkill];
119
+ }
120
+ return INTENT_STAGE_MAP[primaryIntent] || null;
121
+ }
122
+
123
+ // ─── Explicit Request Detection ──────────────────────────────────────────────
124
+
125
+ const EXPLICIT_REQUEST_PATTERNS = [
126
+ // EN: "use the playwright skill", "try the ppt skill"
127
+ /(?:use|try|invoke|run|activate|load)\s+(?:the\s+)?(\S+?)\s+(?:skill|agent|tool|plugin)\b/i,
128
+ // CN: "用ppt的技能", "帮我用playwright的skill"
129
+ /(?:用|使用|帮我用|试试|启用)\s*(\S+?)\s*(?:的|的技能|的skill|的agent|技能|skill|agent|工具|插件)/,
130
+ // "有没有xxx的skill", "is there a xxx agent"
131
+ /(?:有没有|有无|是否有|do you have|is there)\s*(?:一个|a|an)?\s*(\S+?)\s*(?:的|skill|agent|技能|工具)/i,
132
+ // "推荐一个xxx", "recommend a xxx agent"
133
+ /(?:推荐|suggest|recommend)\s*(?:一个|a|an)?\s*(\S+?)\s*(?:的|skill|agent|技能|工具)/i,
134
+ ];
135
+
136
+ /**
137
+ * Detect if the user is explicitly requesting a specific tool/skill.
138
+ * Highest priority — bypasses all dispatch restrictions.
139
+ * @param {string} userPrompt User's prompt text
140
+ * @returns {{isExplicit: boolean, searchTerm?: string}}
141
+ */
142
+ export function detectExplicitRequest(userPrompt) {
143
+ if (!userPrompt) return { isExplicit: false };
144
+
145
+ for (const pattern of EXPLICIT_REQUEST_PATTERNS) {
146
+ const match = userPrompt.match(pattern);
147
+ if (match && match[1]) {
148
+ const term = match[1].replace(/['"]/g, '').trim();
149
+ if (term.length >= 2 && term.length <= 30) {
150
+ return { isExplicit: true, searchTerm: term };
151
+ }
152
+ }
153
+ }
154
+ return { isExplicit: false };
155
+ }
@@ -0,0 +1,222 @@
1
+ // claude-mem-lite: Cross-session handoff extraction, detection, and injection
2
+ // Extracted for testability — hook.mjs has module-level side effects
3
+
4
+ import { basename } from 'path';
5
+ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm } from './utils.mjs';
6
+ import {
7
+ HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
8
+ } from './hook-shared.mjs';
9
+
10
+ /**
11
+ * Build and save a handoff snapshot to session_handoffs table.
12
+ * Called synchronously during handleStop (/exit) or handleSessionStart (/clear).
13
+ * @param {Database} db Opened main database
14
+ * @param {string} sessionId Session being handed off
15
+ * @param {string} project Project identifier
16
+ * @param {'clear'|'exit'} type Handoff type
17
+ * @param {object|null} episodeSnapshot Episode buffer captured before flushing
18
+ */
19
+ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapshot) {
20
+ // 1. Working objective — from user prompts
21
+ const prompts = db.prepare(`
22
+ SELECT prompt_text FROM user_prompts
23
+ WHERE content_session_id = ?
24
+ ORDER BY prompt_number ASC LIMIT 5
25
+ `).all(sessionId);
26
+ if (prompts.length === 0) return; // Empty session — nothing to hand off
27
+
28
+ const workingOn = prompts.map(p => truncate(p.prompt_text, 200)).join(' → ');
29
+
30
+ // 2. Completed — from observations (include narrative for richer handoff)
31
+ const completed = db.prepare(`
32
+ SELECT title, type, narrative FROM observations
33
+ WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
34
+ ORDER BY created_at_epoch DESC LIMIT 15
35
+ `).all(sessionId);
36
+
37
+ // 3. Unfinished — episode snapshot + full session edit history from narratives
38
+ let unfinished = '';
39
+ if (episodeSnapshot?.entries) {
40
+ const pendingDescs = episodeSnapshot.entries
41
+ .filter(e => e.isSignificant || e.isError)
42
+ .map(e => e.desc);
43
+ if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
44
+ }
45
+ // Only the most recent bugfix is an "unfinished" signal (earlier ones are likely resolved)
46
+ if (!unfinished) {
47
+ const lastBugfix = completed.find(o => o.type === 'bugfix');
48
+ if (lastBugfix) unfinished = lastBugfix.title;
49
+ }
50
+ // Enrich unfinished with full session edit history from observation narratives.
51
+ // Since handoff is UPSERT (max 2 rows per project), storing more data is free.
52
+ const narratives = completed
53
+ .filter(c => c.narrative)
54
+ .map(c => c.narrative);
55
+ if (narratives.length > 0) {
56
+ const editHistory = narratives.join('\n');
57
+ unfinished = [unfinished, editHistory].filter(Boolean).join('\n---\n');
58
+ }
59
+
60
+ // 4. Key files — from episode snapshot + observations
61
+ const fileSet = new Set();
62
+ if (episodeSnapshot?.files) episodeSnapshot.files.forEach(f => fileSet.add(f));
63
+ const obsFiles = db.prepare(`
64
+ SELECT files_modified FROM observations
65
+ WHERE memory_session_id = ? AND files_modified IS NOT NULL
66
+ ORDER BY created_at_epoch DESC LIMIT 10
67
+ `).all(sessionId);
68
+ for (const row of obsFiles) {
69
+ try { JSON.parse(row.files_modified).forEach(f => fileSet.add(f)); } catch {}
70
+ }
71
+
72
+ // 5. Key decisions — high importance observations
73
+ const decisions = db.prepare(`
74
+ SELECT title FROM observations
75
+ WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
76
+ AND COALESCE(compressed_into, 0) = 0
77
+ ORDER BY created_at_epoch DESC LIMIT 5
78
+ `).all(sessionId);
79
+
80
+ // 6. Match keywords
81
+ const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
82
+ const keywords = extractMatchKeywords(allText, [...fileSet]);
83
+
84
+ // UPSERT
85
+ db.prepare(`
86
+ INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
+ ON CONFLICT(project, type) DO UPDATE SET
89
+ session_id = excluded.session_id,
90
+ working_on = excluded.working_on,
91
+ completed = excluded.completed,
92
+ unfinished = excluded.unfinished,
93
+ key_files = excluded.key_files,
94
+ key_decisions = excluded.key_decisions,
95
+ match_keywords = excluded.match_keywords,
96
+ created_at_epoch = excluded.created_at_epoch
97
+ `).run(
98
+ project, type, sessionId,
99
+ truncate(workingOn, 1000),
100
+ completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
101
+ truncate(unfinished, 3000),
102
+ JSON.stringify([...fileSet].slice(0, 20)),
103
+ decisions.map(d => d.title).join('\n'),
104
+ keywords,
105
+ Date.now()
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Detect if user's prompt indicates continuation of previous work.
111
+ * Stage 1: Explicit keyword match (zero false positives).
112
+ * Stage 2: FTS5-style term overlap with handoff keywords.
113
+ * @param {Database} db Opened main database
114
+ * @param {string} promptText User's prompt text
115
+ * @param {string} project Project identifier
116
+ * @returns {boolean}
117
+ */
118
+ export function detectContinuationIntent(db, promptText, project) {
119
+ // Stage 1: Explicit keyword match — always works, even without handoff
120
+ if (CONTINUE_KEYWORDS.test(promptText)) return true;
121
+
122
+ // Stage 2: FTS5-style term overlap with handoff keywords
123
+ const handoffs = db.prepare(`
124
+ SELECT type, match_keywords, created_at_epoch FROM session_handoffs
125
+ WHERE project = ? ORDER BY created_at_epoch DESC
126
+ `).all(project);
127
+ if (handoffs.length === 0) return false;
128
+
129
+ // Filter expired handoffs
130
+ const now = Date.now();
131
+ const validHandoffs = handoffs.filter(h => {
132
+ const age = now - h.created_at_epoch;
133
+ const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
134
+ return age <= maxAge;
135
+ });
136
+ if (validHandoffs.length === 0) return false;
137
+
138
+ // Use the most recent valid handoff for keyword matching
139
+ const handoff = validHandoffs[0];
140
+ const promptTokens = tokenizeHandoff(promptText);
141
+ const handoffTokens = new Set(tokenizeHandoff(handoff.match_keywords));
142
+
143
+ let score = 0;
144
+ for (const token of promptTokens) {
145
+ if (handoffTokens.has(token)) {
146
+ score += isSpecificTerm(token) ? 2 : 1;
147
+ }
148
+ }
149
+
150
+ return score >= HANDOFF_MATCH_THRESHOLD;
151
+ }
152
+
153
+ /**
154
+ * Render handoff injection text for stdout.
155
+ * Reads the most recent handoff + optional session summary.
156
+ * @param {Database} db Opened main database
157
+ * @param {string} project Project identifier
158
+ * @returns {string|null} Injection text or null if no handoff
159
+ */
160
+ export function renderHandoffInjection(db, project) {
161
+ const now = Date.now();
162
+ // Fetch recent handoffs and find the most recent non-expired one.
163
+ // A newer but expired 'clear' handoff (1h) must not shadow a still-valid 'exit' handoff (7d).
164
+ const handoffs = db.prepare(`
165
+ SELECT * FROM session_handoffs
166
+ WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
167
+ `).all(project);
168
+ const handoff = handoffs.find(h => {
169
+ const age = now - h.created_at_epoch;
170
+ const maxAge = h.type === 'clear' ? HANDOFF_EXPIRY_CLEAR : HANDOFF_EXPIRY_EXIT;
171
+ return age <= maxAge;
172
+ });
173
+ if (!handoff) return null;
174
+
175
+ const ageSec = Math.round((Date.now() - handoff.created_at_epoch) / 1000);
176
+ const ageStr = ageSec < 60 ? `${ageSec}s` :
177
+ ageSec < 3600 ? `${Math.round(ageSec / 60)}m` :
178
+ ageSec < 86400 ? `${Math.round(ageSec / 3600)}h` :
179
+ `${Math.round(ageSec / 86400)}d`;
180
+
181
+ const lines = [`<session-handoff source="${handoff.type}" age="${ageStr}">`];
182
+
183
+ if (handoff.working_on) {
184
+ lines.push('## Working On', handoff.working_on, '');
185
+ }
186
+ if (handoff.completed) {
187
+ lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
188
+ }
189
+ if (handoff.unfinished) {
190
+ lines.push('## Unfinished', ...handoff.unfinished.split('; ').map(l => `- ${l}`), '');
191
+ }
192
+ if (handoff.key_files) {
193
+ try {
194
+ const files = JSON.parse(handoff.key_files);
195
+ if (files.length > 0) lines.push('## Key Files', files.map(f => basename(f)).join(', '), '');
196
+ } catch {}
197
+ }
198
+ if (handoff.key_decisions) {
199
+ lines.push('## Key Decisions', ...handoff.key_decisions.split('\n').map(l => `- ${l}`), '');
200
+ }
201
+
202
+ lines.push('</session-handoff>');
203
+
204
+ // Append session summary if available (long-gap enrichment)
205
+ try {
206
+ const summary = db.prepare(`
207
+ SELECT completed, next_steps, remaining_items FROM session_summaries
208
+ WHERE memory_session_id = ? AND project = ?
209
+ ORDER BY created_at_epoch DESC LIMIT 1
210
+ `).get(handoff.session_id, project);
211
+ if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
212
+ lines.push('');
213
+ lines.push('<session-summary source="haiku">');
214
+ if (summary.completed) lines.push(summary.completed);
215
+ if (summary.remaining_items) lines.push(`Remaining: ${summary.remaining_items}`);
216
+ if (summary.next_steps) lines.push(`Next steps: ${summary.next_steps}`);
217
+ lines.push('</session-summary>');
218
+ }
219
+ } catch {}
220
+
221
+ return lines.join('\n');
222
+ }
package/install.mjs CHANGED
@@ -564,6 +564,27 @@ const RESOURCE_METADATA = {
564
564
  capability_summary: 'Autonomous skill extraction and continuous learning from Claude Code work sessions',
565
565
  trigger_patterns: 'when user wants to extract reusable skills from work sessions or enable continuous learning',
566
566
  },
567
+ 'skill:mem-memory': {
568
+ intent_tags: 'memory,save,store,remember,note,record,persist,auto-save',
569
+ domain_tags: 'memory,ai,claude',
570
+ capability_summary: 'Save content to memory — with explicit content, instructions, or auto-summarize current session',
571
+ trigger_patterns: 'when user wants to save something to memory or auto-save session highlights',
572
+ invocation_name: 'claude-mem-lite:memory',
573
+ },
574
+ 'skill:mem-update': {
575
+ intent_tags: 'maintenance,cleanup,deduplicate,decay,optimize,reindex,health',
576
+ domain_tags: 'memory,registry,maintenance',
577
+ capability_summary: 'Auto-maintain memory and resource registry — deduplicate, merge, decay, cleanup, reindex',
578
+ trigger_patterns: 'when user wants to clean up memory database or maintain the tool registry',
579
+ invocation_name: 'claude-mem-lite:update',
580
+ },
581
+ 'skill:mem-tools': {
582
+ intent_tags: 'import,tools,github,skills,agents,registry,add,discover',
583
+ domain_tags: 'memory,registry,github,tools',
584
+ capability_summary: 'Import skills and agents from GitHub repositories into the tool resource registry',
585
+ trigger_patterns: 'when user wants to import or add new skills and agents from GitHub to the tool registry',
586
+ invocation_name: 'claude-mem-lite:tools',
587
+ },
567
588
  'skill:anthropic-architect': {
568
589
  intent_tags: 'anthropic,architecture,system-design,claude,patterns,best-practices',
569
590
  domain_tags: 'anthropic,architecture,ai',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.2.3",
3
+ "version": "2.3.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,10 +32,12 @@
32
32
  "hook-semaphore.mjs",
33
33
  "hook-episode.mjs",
34
34
  "hook-context.mjs",
35
+ "hook-handoff.mjs",
35
36
  "haiku-client.mjs",
36
37
  "dispatch.mjs",
37
38
  "dispatch-inject.mjs",
38
39
  "dispatch-feedback.mjs",
40
+ "dispatch-workflow.mjs",
39
41
  "registry.mjs",
40
42
  "registry-retriever.mjs",
41
43
  "registry-indexer.mjs",
@@ -47,6 +49,9 @@
47
49
  "schema.mjs",
48
50
  "skill.md",
49
51
  "commands/mem.md",
52
+ "commands/memory.md",
53
+ "commands/update.md",
54
+ "commands/tools.md",
50
55
  "hooks/hooks.json",
51
56
  "scripts/launch.mjs",
52
57
  "scripts/setup.sh",
package/server.mjs CHANGED
@@ -5,9 +5,11 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { jaccardSimilarity, truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, computeMinHash, scrubSecrets, fmtDate, isoWeekKey, debugLog, debugCatch } from './utils.mjs';
8
- import { ensureDb, DB_PATH } from './schema.mjs';
8
+ import { ensureDb, DB_PATH, DB_DIR } from './schema.mjs';
9
9
  import { reRankWithContext, markSuperseded, extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
10
- import { memSearchSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema } from './tool-schemas.mjs';
10
+ import { memSearchSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memRegistrySchema } from './tool-schemas.mjs';
11
+ import { ensureRegistryDb, upsertResource } from './registry.mjs';
12
+ import { join } from 'path';
11
13
 
12
14
  // ─── Database ───────────────────────────────────────────────────────────────
13
15
 
@@ -34,6 +36,22 @@ try {
34
36
  // Server process uses longer busy_timeout for concurrent MCP requests
35
37
  db.pragma('busy_timeout = 5000');
36
38
 
39
+ // ─── Registry Database (lazy-loaded on first mem_registry call) ─────────────
40
+
41
+ const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
42
+ let registryDb = null;
43
+
44
+ function getRegistryDb() {
45
+ if (registryDb) return registryDb;
46
+ try {
47
+ registryDb = ensureRegistryDb(REGISTRY_DB_PATH);
48
+ registryDb.pragma('busy_timeout = 3000');
49
+ } catch (e) {
50
+ debugLog('WARN', 'server', `Registry DB not available: ${e.message}`);
51
+ }
52
+ return registryDb;
53
+ }
54
+
37
55
  // inferProject, jaccardSimilarity, sanitizeFtsQuery, typeIcon, truncate, fmtDate imported from utils.mjs
38
56
 
39
57
  // ─── Scoring Model Constants ────────────────────────────────────────────────
@@ -977,6 +995,245 @@ server.registerTool(
977
995
  })
978
996
  );
979
997
 
998
+ // ─── Tool: mem_maintain ──────────────────────────────────────────────────────
999
+
1000
+ server.registerTool(
1001
+ 'mem_maintain',
1002
+ {
1003
+ description: 'Memory maintenance: scan for duplicates/stale/broken items, then execute cleanup/decay/boost/dedup operations.',
1004
+ inputSchema: memMaintainSchema,
1005
+ },
1006
+ safeHandler(async (args) => {
1007
+ const STALE_AGE_MS = 30 * 86400000;
1008
+ const SIMILARITY_THRESHOLD = 0.7;
1009
+ const SCAN_LIMIT = 500;
1010
+ const DUPLICATE_LIMIT = 50;
1011
+ const DUPLICATE_DISPLAY = 15;
1012
+
1013
+ const action = args.action;
1014
+ const project = args.project;
1015
+ const projectFilter = project ? 'AND project = ?' : '';
1016
+ const baseParams = project ? [project] : [];
1017
+
1018
+ if (action === 'scan') {
1019
+ // 1. Find near-duplicate titles (pre-compute word sets, then O(n²) Jaccard)
1020
+ const recent = db.prepare(`
1021
+ SELECT id, title, project, importance, access_count, created_at_epoch
1022
+ FROM observations
1023
+ WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1024
+ ORDER BY created_at_epoch DESC
1025
+ LIMIT ${SCAN_LIMIT}
1026
+ `).all(...baseParams);
1027
+
1028
+ const wordSets = recent.map(r => new Set((r.title || '').toLowerCase().split(/\s+/).filter(w => w.length > 2)));
1029
+ const duplicates = [];
1030
+ for (let i = 0; i < recent.length && duplicates.length < DUPLICATE_LIMIT; i++) {
1031
+ if (wordSets[i].size === 0) continue;
1032
+ for (let j = i + 1; j < recent.length; j++) {
1033
+ if (wordSets[j].size === 0) continue;
1034
+ const sim = jaccardSimilarity(wordSets[i], wordSets[j]);
1035
+ if (sim > SIMILARITY_THRESHOLD) {
1036
+ duplicates.push({
1037
+ a: { id: recent[i].id, title: recent[i].title, importance: recent[i].importance },
1038
+ b: { id: recent[j].id, title: recent[j].title, importance: recent[j].importance },
1039
+ similarity: sim.toFixed(2),
1040
+ });
1041
+ }
1042
+ if (duplicates.length >= DUPLICATE_LIMIT) break;
1043
+ }
1044
+ }
1045
+
1046
+ // 2. Consolidated stats query (single table scan instead of 4 separate COUNTs)
1047
+ const staleAge = Date.now() - STALE_AGE_MS;
1048
+ const stats = db.prepare(`
1049
+ SELECT
1050
+ COUNT(*) as total,
1051
+ SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
1052
+ AND created_at_epoch < ? THEN 1 ELSE 0 END) as stale,
1053
+ SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
1054
+ THEN 1 ELSE 0 END) as broken,
1055
+ SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
1056
+ THEN 1 ELSE 0 END) as boostable
1057
+ FROM observations
1058
+ WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
1059
+ `).get(staleAge, ...baseParams);
1060
+
1061
+ const lines = [
1062
+ `Memory maintenance scan:`,
1063
+ ` Total active observations: ${stats.total}`,
1064
+ ` Near-duplicate pairs: ${duplicates.length}`,
1065
+ ` Stale (>30d, imp=1, no access): ${stats.stale}`,
1066
+ ` Broken (no title/narrative): ${stats.broken}`,
1067
+ ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1068
+ ];
1069
+ if (duplicates.length > 0) {
1070
+ lines.push('', 'Top duplicates:');
1071
+ for (const d of duplicates.slice(0, DUPLICATE_DISPLAY)) {
1072
+ lines.push(` [${d.a.id}] "${truncate(d.a.title, 40)}" <-> [${d.b.id}] "${truncate(d.b.title, 40)}" (${d.similarity})`);
1073
+ }
1074
+ }
1075
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1076
+ }
1077
+
1078
+ if (action === 'execute') {
1079
+ const ops = args.operations || [];
1080
+ const results = [];
1081
+ const staleAge = Date.now() - STALE_AGE_MS;
1082
+
1083
+ db.transaction(() => {
1084
+ if (ops.includes('cleanup')) {
1085
+ const deleted = db.prepare(`
1086
+ DELETE FROM observations
1087
+ WHERE COALESCE(compressed_into, 0) = 0
1088
+ AND (title IS NULL OR title = '')
1089
+ AND (narrative IS NULL OR narrative = '')
1090
+ ${projectFilter}
1091
+ `).run(...baseParams);
1092
+ results.push(`Cleaned up ${deleted.changes} broken observations`);
1093
+ }
1094
+
1095
+ if (ops.includes('decay')) {
1096
+ const decayed = db.prepare(`
1097
+ UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
1098
+ WHERE COALESCE(compressed_into, 0) = 0
1099
+ AND COALESCE(importance, 1) > 1
1100
+ AND COALESCE(access_count, 0) = 0
1101
+ AND created_at_epoch < ?
1102
+ ${projectFilter}
1103
+ `).run(staleAge, ...baseParams);
1104
+ results.push(`Decayed ${decayed.changes} stale observations`);
1105
+ }
1106
+
1107
+ if (ops.includes('boost')) {
1108
+ const boosted = db.prepare(`
1109
+ UPDATE observations SET importance = MIN(3, COALESCE(importance, 1) + 1)
1110
+ WHERE COALESCE(compressed_into, 0) = 0
1111
+ AND COALESCE(access_count, 0) > 3
1112
+ AND COALESCE(importance, 1) < 3
1113
+ ${projectFilter}
1114
+ `).run(...baseParams);
1115
+ results.push(`Boosted ${boosted.changes} frequently-accessed observations`);
1116
+ }
1117
+
1118
+ if (ops.includes('dedup') && args.merge_ids) {
1119
+ let totalMerged = 0;
1120
+ const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
1121
+ for (const group of args.merge_ids) {
1122
+ if (group.length < 2) continue;
1123
+ const [keepId, ...removeIds] = group;
1124
+ for (const removeId of removeIds) mergeStmt.run(keepId, removeId);
1125
+ totalMerged += removeIds.length;
1126
+ }
1127
+ results.push(`Merged ${totalMerged} duplicate observations`);
1128
+ }
1129
+ })();
1130
+
1131
+ // FTS5 optimize (outside transaction)
1132
+ db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
1133
+ results.push('FTS5 index optimized');
1134
+
1135
+ return { content: [{ type: 'text', text: results.join('\n') }] };
1136
+ }
1137
+
1138
+ return { content: [{ type: 'text', text: `Unknown action: ${action}. Use "scan" or "execute".` }], isError: true };
1139
+ })
1140
+ );
1141
+
1142
+ // ─── Tool: mem_registry ─────────────────────────────────────────────────────
1143
+
1144
+ server.registerTool(
1145
+ 'mem_registry',
1146
+ {
1147
+ description: 'Manage tool resource registry: list resources, view stats, import/remove tools, reindex FTS5.',
1148
+ inputSchema: memRegistrySchema,
1149
+ },
1150
+ safeHandler(async (args) => {
1151
+ const rdb = getRegistryDb();
1152
+ if (!rdb) {
1153
+ return { content: [{ type: 'text', text: 'Registry DB not available. Run install first.' }], isError: true };
1154
+ }
1155
+
1156
+ const action = args.action;
1157
+
1158
+ if (action === 'list') {
1159
+ const typeFilter = args.type;
1160
+ const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
1161
+ const params = typeFilter ? [typeFilter, 'active'] : ['active'];
1162
+ const resources = rdb.prepare(`
1163
+ SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
1164
+ FROM resources ${where} ORDER BY type, name
1165
+ `).all(...params);
1166
+
1167
+ if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };
1168
+
1169
+ const lines = resources.map(r =>
1170
+ `${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count} adopt:${r.adopt_count} — ${truncate(r.capability_summary || '', 60)}`
1171
+ );
1172
+ return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
1173
+ }
1174
+
1175
+ if (action === 'stats') {
1176
+ const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
1177
+ const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
1178
+ const topAdopted = rdb.prepare(`
1179
+ SELECT name, type, adopt_count, recommend_count
1180
+ FROM resources WHERE status = ? AND adopt_count > 0
1181
+ ORDER BY adopt_count DESC LIMIT 10
1182
+ `).all('active');
1183
+ const zeroAdopt = rdb.prepare(`
1184
+ SELECT COUNT(*) as c FROM resources
1185
+ WHERE status = ? AND recommend_count > 0 AND adopt_count = 0
1186
+ `).get('active');
1187
+ const userAdded = rdb.prepare(`
1188
+ SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'
1189
+ `).get('active');
1190
+
1191
+ const lines = [
1192
+ `Registry Stats:`,
1193
+ ` Total active: ${total.c}`,
1194
+ ...byType.map(t => ` ${t.type}: ${t.c}`),
1195
+ ` User-added: ${userAdded.c}`,
1196
+ ` Zero adoption (recommended but never adopted): ${zeroAdopt.c}`,
1197
+ ];
1198
+ if (topAdopted.length > 0) {
1199
+ lines.push('', 'Top adopted:');
1200
+ for (const r of topAdopted) {
1201
+ lines.push(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
1202
+ }
1203
+ }
1204
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1205
+ }
1206
+
1207
+ if (action === 'import') {
1208
+ if (!args.name || !args.resource_type) {
1209
+ return { content: [{ type: 'text', text: 'import requires name and resource_type' }], isError: true };
1210
+ }
1211
+ const IMPORT_STRING_FIELDS = ['repo_url', 'local_path', 'invocation_name', 'intent_tags',
1212
+ 'domain_tags', 'trigger_patterns', 'capability_summary', 'keywords', 'tech_stack', 'use_cases'];
1213
+ const fields = { name: args.name, type: args.resource_type, status: 'active', source: args.source || 'user' };
1214
+ for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
1215
+ const id = upsertResource(rdb, fields);
1216
+ return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
1217
+ }
1218
+
1219
+ if (action === 'remove') {
1220
+ if (!args.name || !args.resource_type) {
1221
+ return { content: [{ type: 'text', text: 'remove requires name and resource_type' }], isError: true };
1222
+ }
1223
+ const result = rdb.prepare('DELETE FROM resources WHERE type = ? AND name = ?').run(args.resource_type, args.name);
1224
+ return { content: [{ type: 'text', text: result.changes > 0 ? `Removed: ${args.resource_type}:${args.name}` : 'Not found.' }] };
1225
+ }
1226
+
1227
+ if (action === 'reindex') {
1228
+ rdb.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')");
1229
+ const count = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
1230
+ return { content: [{ type: 'text', text: `FTS5 reindexed. ${count.c} active resources.` }] };
1231
+ }
1232
+
1233
+ return { content: [{ type: 'text', text: `Unknown action: ${action}. Valid: list, stats, import, remove, reindex` }], isError: true };
1234
+ })
1235
+ );
1236
+
980
1237
  // ─── WAL Checkpoint (periodic) ───────────────────────────────────────────────
981
1238
 
982
1239
  // Checkpoint WAL every 5 minutes to prevent unbounded growth
@@ -1042,6 +1299,7 @@ function shutdown(exitCode = 0) {
1042
1299
  clearInterval(idleTimer);
1043
1300
  try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
1044
1301
  try { db.close(); } catch {}
1302
+ try { if (registryDb) registryDb.close(); } catch {}
1045
1303
  process.exit(exitCode);
1046
1304
  }
1047
1305
  process.on('SIGINT', () => shutdown(0));
package/tool-schemas.mjs CHANGED
@@ -54,3 +54,30 @@ export const memCompressSchema = {
54
54
  age_days: z.number().int().min(30).max(365).optional().describe('Min age in days (default: 60)'),
55
55
  project: z.string().optional().describe('Filter by project'),
56
56
  };
57
+
58
+ export const memMaintainSchema = {
59
+ action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
60
+ operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost'])).optional()
61
+ .describe('Operations to execute (for action=execute)'),
62
+ merge_ids: z.array(z.array(z.number().int()).min(2)).optional()
63
+ .describe('For dedup: [[keepId, removeId1, removeId2], ...] — first ID in each group is kept'),
64
+ project: z.string().optional().describe('Filter by project'),
65
+ };
66
+
67
+ export const memRegistrySchema = {
68
+ action: z.enum(['list', 'stats', 'import', 'remove', 'reindex']).describe('Registry operation'),
69
+ type: z.enum(['skill', 'agent']).optional().describe('Filter by resource type (for list)'),
70
+ name: z.string().optional().describe('Resource name (for import/remove)'),
71
+ resource_type: z.enum(['skill', 'agent']).optional().describe('Resource type (for import/remove)'),
72
+ source: z.enum(['preinstalled', 'user']).optional().describe('Source (for import, default: user)'),
73
+ repo_url: z.string().optional().describe('GitHub repository URL (for import)'),
74
+ local_path: z.string().optional().describe('Local file path (for import)'),
75
+ invocation_name: z.string().optional().describe('Invocation name like "plugin:skill" (for import)'),
76
+ intent_tags: z.string().optional().describe('Comma-separated intent tags (for import)'),
77
+ domain_tags: z.string().optional().describe('Comma-separated domain/tech tags (for import)'),
78
+ trigger_patterns: z.string().optional().describe('When to recommend this tool (for import)'),
79
+ capability_summary: z.string().optional().describe('What this tool does (for import)'),
80
+ keywords: z.string().optional().describe('Search keywords (for import)'),
81
+ tech_stack: z.string().optional().describe('Technology stack tags (for import)'),
82
+ use_cases: z.string().optional().describe('Usage scenarios (for import)'),
83
+ };