From ea7b293299f830734a186e5a92a802c167912722 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Tue, 3 Feb 2026 18:11:52 +0900 Subject: [PATCH 01/21] feat: Implement AI enrichment for observations and enhance session management - Added ai-enrichment.ts to handle AI-generated insights for tool observations. - Integrated AI enrichment into MemoryHookService for enhanced observation data. - Introduced user prompt tracking with saveUserPrompt and getSessionPrompts methods. - Enhanced session summary generation to include structured summaries and user prompts. - Updated database schema to support new fields for observations and session summaries. - Improved markdown generation to include recent user prompts and session summaries. - Added utility functions for extracting facts and concepts from tool usage. - Updated vitest configuration to include setup files for testing. --- package-lock.json | 318 ++++++++++++ package.json | 1 + src/__tests__/setup.ts | 8 + src/cli/web-viewer.ts | 234 ++++++++- src/hooks/__tests__/ai-enrichment.test.ts | 586 ++++++++++++++++++++++ src/hooks/__tests__/handlers.test.ts | 108 +++- src/hooks/__tests__/integration.test.ts | 4 +- src/hooks/__tests__/service.test.ts | 8 +- src/hooks/__tests__/types.test.ts | 217 ++++++++ src/hooks/ai-enrichment.ts | 225 +++++++++ src/hooks/service.ts | 428 ++++++++++++++-- src/hooks/session-init.ts | 9 + src/hooks/summarize.ts | 12 +- src/hooks/types.ts | 330 ++++++++++++ vitest.config.ts | 3 +- 15 files changed, 2409 insertions(+), 82 deletions(-) create mode 100644 src/__tests__/setup.ts create mode 100644 src/hooks/__tests__/ai-enrichment.test.ts create mode 100644 src/hooks/ai-enrichment.ts diff --git a/package-lock.json b/package-lock.json index d928f04..d4745bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.29", "better-sqlite3": "^11.0.0" }, "bin": { @@ -33,6 +34,28 @@ "@xenova/transformers": "^2.17.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.29.tgz", + "integrity": "sha512-b+655n4ZqqAiMQEL3P44e9UurkI7WWanWTQQQTEcKngL5YCjjXExEPEJRxrmqp8mQXs0kLErZhObx0ZuwibOhA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -545,6 +568,291 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2673,6 +2981,16 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 31cf880..8229011 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "access": "public" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.29", "better-sqlite3": "^11.0.0" }, "optionalDependencies": { diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..f2f36c9 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,8 @@ +/** + * Global test setup + * + * Disables AI enrichment by default to prevent slow SDK calls + * during tests. The ai-enrichment.test.ts file manages this + * env var independently for its own test cases. + */ +process.env.AGENTKITS_AI_ENRICHMENT = 'false'; diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index 197a815..e25f4ed 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -45,7 +45,7 @@ let _searchEngine: HybridSearchEngine | null = null; let _db: BetterDatabase | null = null; /** - * Get direct database access + * Get direct database access (memory.db) */ function getDatabase(): BetterDatabase { if (_db) return _db; @@ -1000,6 +1000,20 @@ function getHTML(): string { + +
+ + +
+ + +
@@ -1020,6 +1034,15 @@ function getHTML(): string {
+ + + + @@ -1626,6 +1649,154 @@ function getHTML(): string { } }); + // Tab switching + function switchTab(tab) { + document.getElementById('memories-tab').style.display = tab === 'memories' ? '' : 'none'; + document.getElementById('sessions-tab').style.display = tab === 'sessions' ? '' : 'none'; + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.style.borderBottomColor = 'transparent'; + btn.style.color = 'var(--text-secondary)'; + }); + const activeBtn = document.getElementById('tab-' + tab); + activeBtn.style.borderBottomColor = 'var(--accent)'; + activeBtn.style.color = 'var(--text-primary)'; + + if (tab === 'sessions') loadSessions(); + } + + // Sessions feed + async function loadSessions() { + const feed = document.getElementById('sessions-feed'); + const statsEl = document.getElementById('sessions-stats'); + try { + const [sessRes, obsRes] = await Promise.all([ + fetch('/api/sessions?limit=20'), + fetch('/api/observations?limit=50') + ]); + const data = await sessRes.json(); + const observations = await obsRes.json(); + + // Stats + statsEl.innerHTML = \` +
+
Sessions
+
\${data.sessions?.length || 0}
+
+
+
User Prompts
+
\${data.prompts?.length || 0}
+
+
+
Summaries
+
\${data.summaries?.length || 0}
+
+
+
Observations
+
\${observations?.length || 0}
+
+ \`; + + // Build timeline feed (mix prompts, summaries, observations) + const items = []; + + for (const p of (data.prompts || [])) { + items.push({ + type: 'prompt', + time: p.created_at, + sessionId: p.session_id, + promptNumber: p.prompt_number, + text: p.prompt_text, + project: p.project, + }); + } + + for (const s of (data.summaries || [])) { + items.push({ + type: 'summary', + time: s.created_at, + sessionId: s.session_id, + request: s.request, + completed: s.completed, + filesModified: s.files_modified, + nextSteps: s.next_steps, + notes: s.notes, + project: s.project, + }); + } + + for (const o of observations.slice(0, 30)) { + items.push({ + type: 'observation', + time: o.timestamp, + sessionId: o.session_id, + toolName: o.tool_name, + title: o.title, + obsType: o.type, + promptNumber: o.prompt_number, + }); + } + + items.sort((a, b) => (b.time || 0) - (a.time || 0)); + + if (items.length === 0) { + feed.innerHTML = '
No session data yet. Hook data will appear here after sessions run.
'; + return; + } + + feed.innerHTML = items.map(item => { + const time = new Date(item.time).toLocaleString(); + const sid = (item.sessionId || '').substring(0, 8); + + if (item.type === 'prompt') { + return \`
+
+ PROMPT #\${item.promptNumber || '?'} + +
+
\${escapeHtml(item.text || '')}
+
\`; + } + + if (item.type === 'summary') { + let filesStr = ''; + try { filesStr = JSON.parse(item.filesModified || '[]').join(', '); } catch {} + return \`
+
+ SUMMARY + +
+
+ \${item.request ? '
Request: ' + escapeHtml(item.request) + '
' : ''} + \${item.completed ? '
Completed: ' + escapeHtml(item.completed) + '
' : ''} + \${filesStr ? '
Files: ' + escapeHtml(filesStr) + '
' : ''} + \${item.nextSteps ? '
Next: ' + escapeHtml(item.nextSteps) + '
' : ''} +
+
\`; + } + + // observation + const icons = { read: '📖', write: '✏️', execute: '⚡', search: '🔍' }; + const icon = icons[item.obsType] || '•'; + return \`
+
+ \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(item.title || '')} + \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''} +
+
\`; + }).join(''); + + } catch (err) { + feed.innerHTML = '
Error loading sessions: ' + err.message + '
'; + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + loadData(); @@ -1938,6 +2109,67 @@ function handleRequest( return; } + // GET sessions data (sessions, prompts, summaries) - all in memory.db now + if (url.pathname === '/api/sessions' && method === 'GET') { + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + try { + const sessions = db.prepare(` + SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? + `).all(limit) as Record[]; + + // user_prompts may not exist in older DBs + let prompts: Record[] = []; + try { + prompts = db.prepare(` + SELECT up.*, s.project FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + ORDER BY up.created_at DESC LIMIT ? + `).all(limit) as Record[]; + } catch { /* table may not exist */ } + + // session_summaries may not exist in older DBs + let summaries: Record[] = []; + try { + summaries = db.prepare(` + SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT ? + `).all(limit) as Record[]; + } catch { /* table may not exist */ } + + res.writeHead(200); + res.end(JSON.stringify({ sessions, prompts, summaries })); + } catch (error) { + res.writeHead(200); + res.end(JSON.stringify({ sessions: [], prompts: [], summaries: [], error: String(error) })); + } + return; + } + + // GET observations from memory.db + if (url.pathname === '/api/observations' && method === 'GET') { + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const sessionId = url.searchParams.get('session_id') || undefined; + + try { + let rows: Record[]; + if (sessionId) { + rows = db.prepare(` + SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp DESC LIMIT ? + `).all(sessionId, limit) as Record[]; + } else { + rows = db.prepare(` + SELECT * FROM observations ORDER BY timestamp DESC LIMIT ? + `).all(limit) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(rows)); + } catch { + res.writeHead(200); + res.end(JSON.stringify([])); + } + return; + } + res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); } catch (error) { diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts new file mode 100644 index 0000000..a0e7461 --- /dev/null +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -0,0 +1,586 @@ +/** + * Unit Tests for AI Enrichment Module + * + * Tests the enrichment logic, env toggle, fallback behavior, + * parseAIResponse, buildExtractionPrompt, and mock SDK flow. + * + * @module @agentkits/memory/hooks/__tests__/ai-enrichment + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + enrichWithAI, + isAIEnrichmentAvailable, + resetAIEnrichmentCache, + parseAIResponse, + buildExtractionPrompt, + _setQueryFunctionForTesting, + type QueryFunction, +} from '../ai-enrichment.js'; + +describe('AI Enrichment Module', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + resetAIEnrichmentCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + _setQueryFunctionForTesting(null); + resetAIEnrichmentCache(); + }); + + describe('environment variable control', () => { + it('should return null when AGENTKITS_AI_ENRICHMENT=false', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when AGENTKITS_AI_ENRICHMENT=0', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = '0'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + expect(result).toBeNull(); + }); + + it('should attempt enrichment when AGENTKITS_AI_ENRICHMENT=true', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + // Returns enriched data if SDK available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + expect(Array.isArray(result.facts)).toBe(true); + expect(Array.isArray(result.concepts)).toBe(true); + } + }); + + it('should auto-detect when env not set', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); + // Returns enriched data if SDK available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + } + }); + + it('should handle AGENTKITS_AI_ENRICHMENT=1', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = '1'; + resetAIEnrichmentCache(); + const result = await enrichWithAI('Read', '{}', '{}'); + // Returns enriched data if SDK available, null otherwise + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + } + }); + }); + + describe('isAIEnrichmentAvailable', () => { + it('should return false when env disabled', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const available = await isAIEnrichmentAvailable(); + expect(available).toBe(false); + }); + + it('should return boolean when auto-detecting', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const available = await isAIEnrichmentAvailable(); + expect(typeof available).toBe('boolean'); + }); + + it('should return true when mock query function is set', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn = createMockQueryFn('{}'); + _setQueryFunctionForTesting(mockFn); + const available = await isAIEnrichmentAvailable(); + expect(available).toBe(true); + }); + }); + + describe('resetAIEnrichmentCache', () => { + it('should reset cached state', async () => { + // First call with env=false should return null + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const disabledResult = await enrichWithAI('Read', '{}', '{}'); + expect(disabledResult).toBeNull(); + + // Reset cache + resetAIEnrichmentCache(); + + // Now with auto-detect, result depends on SDK availability + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{}', '{}'); + // If SDK is available, returns enriched data; otherwise null + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + } + }); + }); + + describe('_setQueryFunctionForTesting', () => { + it('should inject a mock query function', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const validResponse = JSON.stringify({ + subtitle: 'Reading test file', + narrative: 'Read the test file to understand its contents.', + facts: ['File has 10 lines'], + concepts: ['testing'], + }); + const mockFn = createMockQueryFn(validResponse); + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', 'file contents'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Reading test file'); + expect(result!.narrative).toBe('Read the test file to understand its contents.'); + expect(result!.facts).toEqual(['File has 10 lines']); + expect(result!.concepts).toEqual(['testing']); + }); + + it('should clear mock when set to null', async () => { + const mockFn = createMockQueryFn('{}'); + _setQueryFunctionForTesting(mockFn); + _setQueryFunctionForTesting(null); + + // After clearing, should fall back to SDK detection + const available = await isAIEnrichmentAvailable(); + // SDK not installed in test env + expect(typeof available).toBe('boolean'); + }); + }); + + describe('buildExtractionPrompt', () => { + it('should include tool name, input, and response', () => { + const prompt = buildExtractionPrompt('Read', '{"file_path":"src/index.ts"}', 'file content here'); + expect(prompt).toContain('Tool: Read'); + expect(prompt).toContain('Input: {"file_path":"src/index.ts"}'); + expect(prompt).toContain('Response: file content here'); + }); + + it('should truncate long input to 2000 chars', () => { + const longInput = 'x'.repeat(5000); + const prompt = buildExtractionPrompt('Read', longInput, 'short'); + expect(prompt).toContain('Input: ' + 'x'.repeat(2000)); + expect(prompt).not.toContain('x'.repeat(2001)); + }); + + it('should truncate long response to 2000 chars', () => { + const longResponse = 'y'.repeat(5000); + const prompt = buildExtractionPrompt('Read', 'short', longResponse); + expect(prompt).toContain('Response: ' + 'y'.repeat(2000)); + expect(prompt).not.toContain('y'.repeat(2001)); + }); + + it('should include JSON structure instructions', () => { + const prompt = buildExtractionPrompt('Bash', 'ls', 'output'); + expect(prompt).toContain('"subtitle"'); + expect(prompt).toContain('"narrative"'); + expect(prompt).toContain('"facts"'); + expect(prompt).toContain('"concepts"'); + }); + }); + + describe('parseAIResponse', () => { + it('should parse valid JSON', () => { + const json = JSON.stringify({ + subtitle: 'Test subtitle', + narrative: 'Test narrative sentence.', + facts: ['Fact 1', 'Fact 2'], + concepts: ['concept1', 'concept2'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test subtitle'); + expect(result!.narrative).toBe('Test narrative sentence.'); + expect(result!.facts).toEqual(['Fact 1', 'Fact 2']); + expect(result!.concepts).toEqual(['concept1', 'concept2']); + }); + + it('should strip ```json code fences', () => { + const json = '```json\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should strip ``` code fences without json tag', () => { + const json = '```\n{"subtitle":"Test","narrative":"Test.","facts":["f"],"concepts":["c"]}\n```'; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should handle whitespace around JSON', () => { + const json = ' \n {"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]} \n '; + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test'); + }); + + it('should return null for invalid JSON', () => { + const result = parseAIResponse('not json at all'); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = parseAIResponse(''); + expect(result).toBeNull(); + }); + + it('should return null when subtitle is not a string', () => { + const json = JSON.stringify({ + subtitle: 123, + narrative: 'Test.', + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when narrative is not a string', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: null, + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when facts is not an array', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: 'not array', + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should return null when concepts is not an array', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: 'not array', + }); + const result = parseAIResponse(json); + expect(result).toBeNull(); + }); + + it('should truncate subtitle to 200 chars', () => { + const json = JSON.stringify({ + subtitle: 'A'.repeat(300), + narrative: 'Test.', + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.subtitle.length).toBe(200); + }); + + it('should truncate narrative to 500 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'B'.repeat(600), + facts: [], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.narrative.length).toBe(500); + }); + + it('should limit facts to 5 items', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7'], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts.length).toBe(5); + }); + + it('should limit concepts to 5 items', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts.length).toBe(5); + }); + + it('should truncate individual fact strings to 200 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: ['C'.repeat(300)], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts[0].length).toBe(200); + }); + + it('should truncate individual concept strings to 50 chars', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: ['D'.repeat(100)], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts[0].length).toBe(50); + }); + + it('should convert non-string fact values to strings', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [42, true, null], + concepts: [], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.facts).toEqual(['42', 'true', 'null']); + }); + + it('should convert non-string concept values to strings', () => { + const json = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: [42, false], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.concepts).toEqual(['42', 'false']); + }); + }); + + describe('enrichWithAI with mock SDK', () => { + it('should return enriched observation on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const validResponse = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module to understand login flow.', + facts: ['File has 200 lines', 'Uses JWT tokens'], + concepts: ['authentication', 'jwt', 'typescript'], + }); + _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + + const result = await enrichWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Examining auth module'); + expect(result!.facts).toHaveLength(2); + expect(result!.concepts).toContain('jwt'); + }); + + it('should return null when SDK returns empty result', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setQueryFunctionForTesting(createMockQueryFn('')); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when SDK returns invalid JSON', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setQueryFunctionForTesting(createMockQueryFn('not valid json')); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when SDK returns incomplete structure', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setQueryFunctionForTesting(createMockQueryFn('{"subtitle":"test"}')); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should handle SDK stream with no result message', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + // Mock that emits messages but none with type=result/subtype=success + const mockFn: QueryFunction = () => { + return (async function* () { + yield { type: 'progress', subtype: 'update' }; + yield { type: 'done', subtype: 'complete' }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should handle SDK stream with result but no text', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn: QueryFunction = () => { + return (async function* () { + yield { type: 'result', subtype: 'success', result: '' }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should handle SDK stream with result=undefined', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn: QueryFunction = () => { + return (async function* () { + yield { type: 'result', subtype: 'success' }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when mock query function throws', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn: QueryFunction = () => { + throw new Error('SDK error'); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when mock query async iterator throws', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn: QueryFunction = () => { + return (async function* () { + throw new Error('Stream error'); + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should respect timeout with slow mock', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const mockFn: QueryFunction = () => { + return (async function* () { + // Simulate slow response + await new Promise((resolve) => setTimeout(resolve, 5000)); + yield { + type: 'result', + subtype: 'success', + result: '{"subtitle":"slow","narrative":"slow.","facts":[],"concepts":[]}', + }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const start = Date.now(); + const result = await enrichWithAI('Read', '{}', '{}', 100); + const elapsed = Date.now() - start; + + expect(result).toBeNull(); + // Should resolve in ~100ms, not 5000ms + expect(elapsed).toBeLessThan(1000); + }); + + it('should work with AGENTKITS_AI_ENRICHMENT=true and mock', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + const validResponse = JSON.stringify({ + subtitle: 'Running tests', + narrative: 'Executed test suite.', + facts: ['5 tests passed'], + concepts: ['testing'], + }); + _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + + const result = await enrichWithAI('Bash', 'npm test', '5 passed'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Running tests'); + }); + + it('should still return null when env=false even with mock set', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + const validResponse = JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: [], + }); + _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should parse markdown-fenced response from mock SDK', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const fencedResponse = + '```json\n{"subtitle":"Fenced","narrative":"Fenced response.","facts":["f1"],"concepts":["c1"]}\n```'; + _setQueryFunctionForTesting(createMockQueryFn(fencedResponse)); + + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Fenced'); + }); + }); + + describe('error handling', () => { + it('should not throw on enrichment failure', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + // Should gracefully handle any input without throwing + const result = await enrichWithAI('InvalidTool', 'not json', 'not json'); + // May return enriched data if SDK is available, or null if not + if (result !== null) { + expect(typeof result.subtitle).toBe('string'); + expect(typeof result.narrative).toBe('string'); + } + }); + + it('should respect timeout (returns null on slow response)', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + const start = Date.now(); + const result = await enrichWithAI('Read', '{}', '{}', 100); + const elapsed = Date.now() - start; + expect(result).toBeNull(); + expect(elapsed).toBeLessThan(5000); + }); + }); +}); + +/** + * Helper: Create a mock QueryFunction that yields a single result message + */ +function createMockQueryFn(resultText: string): QueryFunction { + return () => { + return (async function* () { + yield { + type: 'result', + subtype: 'success', + result: resultText, + total_cost_usd: 0.001, + }; + })(); + }; +} diff --git a/src/hooks/__tests__/handlers.test.ts b/src/hooks/__tests__/handlers.test.ts index 3741d88..0982558 100644 --- a/src/hooks/__tests__/handlers.test.ts +++ b/src/hooks/__tests__/handlers.test.ts @@ -85,11 +85,28 @@ describe('Hook Handlers', () => { expect(result.additionalContext).toBeUndefined(); }); - it('should return context for existing project', async () => { - // Set up existing data + it('should return context with prompts and summaries', async () => { + // Set up session with prompts, observations, and structured summary const service = new MemoryHookService(TEST_DIR); - await service.initSession('old-session', 'test-project', 'Previous task'); + await service.initSession('old-session', 'test-project', 'Implement auth'); + await service.saveUserPrompt('old-session', 'test-project', 'Implement auth'); + await service.saveUserPrompt('old-session', 'test-project', 'Add tests'); await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + await service.storeObservation('old-session', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); + + // Save structured summary + await service.saveSessionSummary({ + sessionId: 'old-session', + project: 'test-project', + request: '[#1] Implement auth → [#2] Add tests', + completed: '2 file(s) modified', + filesRead: ['file.ts'], + filesModified: ['auth.ts'], + nextSteps: 'Deploy to staging', + notes: '', + promptNumber: 2, + }); + await service.completeSession('old-session', 'Done'); await service.shutdown(); @@ -103,7 +120,11 @@ describe('Hook Handlers', () => { expect(result.suppressOutput).toBe(false); expect(result.additionalContext).toBeDefined(); expect(result.additionalContext).toContain('# Memory Context'); - expect(result.additionalContext).toContain('test-project'); + expect(result.additionalContext).toContain('Previous Session Summaries'); + expect(result.additionalContext).toContain('Implement auth'); + expect(result.additionalContext).toContain('Recent User Prompts'); + expect(result.additionalContext).toContain('Add tests'); + expect(result.additionalContext).toContain('auth.ts'); }); it('should handle errors gracefully', async () => { @@ -144,24 +165,53 @@ describe('Hook Handlers', () => { expect(session?.prompt).toBe('Hello Claude'); }); - it('should not overwrite existing session', async () => { - // Create initial session + it('should save all user prompts (not just first)', async () => { + // First prompt const hook1 = trackHook(createSessionInitHook(TEST_DIR)); await hook1.execute(createTestInput({ prompt: 'First prompt' })); await hook1.shutdown(); - // Try to re-init with different prompt + // Second prompt (same session) const hook2 = trackHook(createSessionInitHook(TEST_DIR)); await hook2.execute(createTestInput({ prompt: 'Second prompt' })); await hook2.shutdown(); - // Verify original prompt preserved + // Third prompt + const hook3 = trackHook(createSessionInitHook(TEST_DIR)); + await hook3.execute(createTestInput({ prompt: 'Third prompt' })); + await hook3.shutdown(); + + // Verify session prompt still has first prompt const service = new MemoryHookService(TEST_DIR); await service.initialize(); const session = service.getSession('test-session-123'); - await service.shutdown(); expect(session?.prompt).toBe('First prompt'); + + // Verify ALL prompts are saved in user_prompts table + const prompts = await service.getSessionPrompts('test-session-123'); + await service.shutdown(); + + expect(prompts.length).toBe(3); + expect(prompts[0].promptNumber).toBe(1); + expect(prompts[0].promptText).toBe('First prompt'); + expect(prompts[1].promptNumber).toBe(2); + expect(prompts[1].promptText).toBe('Second prompt'); + expect(prompts[2].promptNumber).toBe(3); + expect(prompts[2].promptText).toBe('Third prompt'); + }); + + it('should not save prompt when prompt is empty', async () => { + const hook = trackHook(createSessionInitHook(TEST_DIR)); + await hook.execute(createTestInput({ prompt: undefined })); + await hook.shutdown(); + + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const prompts = await service.getSessionPrompts('test-session-123'); + await service.shutdown(); + + expect(prompts.length).toBe(0); }); it('should handle errors gracefully', async () => { @@ -179,10 +229,10 @@ describe('Hook Handlers', () => { }); describe('ObservationHook', () => { - it('should store observation', async () => { - // Initialize session first + it('should store observation with prompt number', async () => { + // Initialize session and save a prompt const initHook = trackHook(createSessionInitHook(TEST_DIR)); - await initHook.execute(createTestInput()); + await initHook.execute(createTestInput({ prompt: 'Fix the bug' })); await initHook.shutdown(); // Store observation @@ -199,7 +249,7 @@ describe('Hook Handlers', () => { expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); - // Verify observation was stored + // Verify observation was stored with prompt number const service = new MemoryHookService(TEST_DIR); await service.initialize(); const observations = await service.getSessionObservations('test-session-123'); @@ -207,6 +257,7 @@ describe('Hook Handlers', () => { expect(observations.length).toBe(1); expect(observations[0].toolName).toBe('Read'); + expect(observations[0].promptNumber).toBe(1); }); it('should skip if no tool name', async () => { @@ -284,15 +335,17 @@ describe('Hook Handlers', () => { }); describe('SummarizeHook', () => { - it('should complete session with summary', async () => { - // Set up session with observations + it('should complete session with structured summary', async () => { + // Set up session with prompts and observations const service = new MemoryHookService(TEST_DIR); - await service.initSession('test-session-123', 'test-project'); - await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); - await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Fix authentication bug'); + await service.saveUserPrompt('test-session-123', 'test-project', 'Fix authentication bug'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'auth.ts' }, {}, TEST_DIR); + await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'auth.ts' }, {}, TEST_DIR); + await service.storeObservation('test-session-123', 'test-project', 'Bash', { command: 'npm test' }, {}, TEST_DIR); await service.shutdown(); - // Run summarize hook (it already calls shutdown internally) + // Run summarize hook const hook = trackHook(createSummarizeHook(TEST_DIR)); const input = createTestInput(); @@ -301,15 +354,26 @@ describe('Hook Handlers', () => { expect(result.continue).toBe(true); expect(result.suppressOutput).toBe(true); - // Verify session was completed + // Verify session was completed with text summary const service2 = new MemoryHookService(TEST_DIR); await service2.initialize(); const session = service2.getSession('test-session-123'); - await service2.shutdown(); expect(session?.status).toBe('completed'); expect(session?.summary).toBeDefined(); - expect(session?.summary).toContain('file'); + expect(session?.summary).toContain('Request:'); + expect(session?.summary).toContain('Fix authentication bug'); + + // Verify structured summary was saved + const summaries = await service2.getRecentSummaries('test-project'); + await service2.shutdown(); + + expect(summaries.length).toBe(1); + expect(summaries[0].request).toContain('Fix authentication bug'); + expect(summaries[0].filesRead).toContain('auth.ts'); + expect(summaries[0].filesModified).toContain('auth.ts'); + expect(summaries[0].completed).toContain('file(s) modified'); + expect(summaries[0].notes).toContain('npm test'); }); it('should handle non-existent session', async () => { diff --git a/src/hooks/__tests__/integration.test.ts b/src/hooks/__tests__/integration.test.ts index bf2f121..55c5ce0 100644 --- a/src/hooks/__tests__/integration.test.ts +++ b/src/hooks/__tests__/integration.test.ts @@ -210,9 +210,9 @@ describe('Hook System Integration', () => { expect(contextResult.continue).toBe(true); expect(contextResult.suppressOutput).toBe(false); expect(contextResult.additionalContext).toBeDefined(); - expect(contextResult.additionalContext).toContain('Previous Sessions'); + expect(contextResult.additionalContext).toContain('Previous Session Summaries'); expect(contextResult.additionalContext).toContain('Recent Activity'); - expect(contextResult.additionalContext).toContain('Write'); + expect(contextResult.additionalContext).toContain('auth.ts'); }); it('should handle multiple projects independently', async () => { diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 789dfe1..c56b569 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -47,7 +47,7 @@ describe('MemoryHookService', () => { // Let's verify by adding some data and persisting await service.initSession('test', 'test-project'); - const dbPath = path.join(TEST_DIR, '.claude/memory', 'hooks.db'); + const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); }); @@ -386,7 +386,7 @@ describe('MemoryHookService', () => { const summary = await service.generateSummary('session-1'); - expect(summary).toBe('No activity recorded in this session.'); + expect(summary).toContain('No activity recorded'); }); it('should list files in summary', async () => { @@ -420,7 +420,7 @@ describe('MemoryHookService', () => { const summary = await service.generateSummary('session-1'); - expect(summary).toContain('7 files touched'); + expect(summary).toContain('7 file(s) modified'); }); }); @@ -432,7 +432,7 @@ describe('MemoryHookService', () => { await service.shutdown(); // Delete the database file - const dbPath = path.join(TEST_DIR, '.claude/memory', 'hooks.db'); + const dbPath = path.join(TEST_DIR, '.claude/memory', 'memory.db'); expect(existsSync(dbPath)).toBe(true); rmSync(dbPath); expect(existsSync(dbPath)).toBe(false); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index 87329a6..4599a25 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -10,6 +10,11 @@ import { getProjectName, getObservationType, generateObservationTitle, + generateObservationSubtitle, + generateObservationNarrative, + extractFilePaths, + extractFacts, + extractConcepts, truncate, parseHookInput, formatResponse, @@ -338,4 +343,216 @@ describe('Hook Types Utilities', () => { expect(STANDARD_RESPONSE.suppressOutput).toBe(true); }); }); + + describe('extractFilePaths', () => { + it('should extract read file paths', () => { + const result = extractFilePaths('Read', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual(['/path/to/file.ts']); + expect(result.filesModified).toEqual([]); + }); + + it('should extract write file paths', () => { + const result = extractFilePaths('Write', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual(['/path/to/file.ts']); + }); + + it('should extract edit file paths', () => { + const result = extractFilePaths('Edit', { file_path: '/path/to/file.ts' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual(['/path/to/file.ts']); + }); + + it('should use path fallback', () => { + const result = extractFilePaths('Read', { path: '/path/to/dir' }); + expect(result.filesRead).toEqual(['/path/to/dir']); + }); + + it('should return empty for Bash', () => { + const result = extractFilePaths('Bash', { command: 'npm test' }); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual([]); + }); + + it('should handle null input', () => { + const result = extractFilePaths('Read', null); + expect(result.filesRead).toEqual([]); + expect(result.filesModified).toEqual([]); + }); + + it('should handle string input', () => { + const result = extractFilePaths('Read', JSON.stringify({ file_path: '/path/file.ts' })); + expect(result.filesRead).toEqual(['/path/file.ts']); + }); + }); + + describe('generateObservationSubtitle', () => { + it('should generate subtitle for Read', () => { + const subtitle = generateObservationSubtitle('Read', { file_path: '/src/index.ts' }); + expect(subtitle).toBe('Examining index.ts'); + }); + + it('should generate subtitle for Write', () => { + const subtitle = generateObservationSubtitle('Write', { file_path: '/src/auth.ts' }); + expect(subtitle).toBe('Creating/updating auth.ts'); + }); + + it('should generate subtitle for Edit', () => { + const subtitle = generateObservationSubtitle('Edit', { file_path: '/src/utils.ts' }); + expect(subtitle).toBe('Modifying utils.ts'); + }); + + it('should generate subtitle for Bash with known commands', () => { + expect(generateObservationSubtitle('Bash', { command: 'npm test' })).toBe('Running npm command'); + expect(generateObservationSubtitle('Bash', { command: 'git status' })).toBe('Git operation'); + expect(generateObservationSubtitle('Bash', { command: 'docker build .' })).toBe('Docker operation'); + }); + + it('should generate subtitle for Glob', () => { + const subtitle = generateObservationSubtitle('Glob', { pattern: '**/*.ts' }); + expect(subtitle).toBe('Searching for **/*.ts pattern'); + }); + + it('should generate subtitle for Grep', () => { + const subtitle = generateObservationSubtitle('Grep', { pattern: 'function' }); + expect(subtitle).toBe('Searching code for "function"'); + }); + + it('should generate subtitle for Task', () => { + const subtitle = generateObservationSubtitle('Task', { subagent_type: 'Explore' }); + expect(subtitle).toBe('Delegating to Explore'); + }); + + it('should generate subtitle for WebSearch', () => { + const subtitle = generateObservationSubtitle('WebSearch', { query: 'typescript best practices' }); + expect(subtitle).toContain('typescript best practices'); + }); + + it('should handle unknown tools', () => { + const subtitle = generateObservationSubtitle('CustomTool', {}); + expect(subtitle).toBe('Using CustomTool tool'); + }); + }); + + describe('generateObservationNarrative', () => { + it('should generate narrative for Read', () => { + const narrative = generateObservationNarrative('Read', { file_path: '/src/index.ts' }); + expect(narrative).toContain('/src/index.ts'); + expect(narrative).toContain('Read'); + }); + + it('should generate narrative for Write', () => { + const narrative = generateObservationNarrative('Write', { file_path: '/src/new.ts' }); + expect(narrative).toContain('/src/new.ts'); + expect(narrative).toContain('Wrote'); + }); + + it('should generate narrative for Bash test commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'npm test' }); + expect(narrative).toContain('test'); + }); + + it('should generate narrative for Bash build commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'tsc' }); + expect(narrative).toContain('Built'); + }); + + it('should generate narrative for Grep', () => { + const narrative = generateObservationNarrative('Grep', { pattern: 'TODO', path: 'src' }); + expect(narrative).toContain('TODO'); + expect(narrative).toContain('src'); + }); + + it('should handle unknown tools', () => { + const narrative = generateObservationNarrative('CustomTool', {}); + expect(narrative).toBe('Used CustomTool tool.'); + }); + }); + + describe('extractFacts', () => { + it('should extract facts from Read', () => { + const facts = extractFacts('Read', { file_path: '/src/index.ts' }, {}); + expect(facts).toContain('File read: /src/index.ts'); + }); + + it('should extract facts from Write', () => { + const facts = extractFacts('Write', { file_path: '/src/new.ts' }, {}); + expect(facts).toContain('File created/updated: /src/new.ts'); + }); + + it('should extract facts from Edit', () => { + const facts = extractFacts('Edit', { file_path: '/src/index.ts', old_string: 'old code' }, {}); + expect(facts.length).toBe(2); + expect(facts[0]).toContain('/src/index.ts'); + expect(facts[1]).toContain('replaced'); + }); + + it('should extract facts from Bash with test results', () => { + const facts = extractFacts('Bash', { command: 'npm test' }, { stdout: '5 tests passed' }); + expect(facts.some(f => f.includes('npm test'))).toBe(true); + expect(facts.some(f => f === 'Tests passed')).toBe(true); + }); + + it('should extract facts from Bash with errors', () => { + const facts = extractFacts('Bash', { command: 'tsc' }, { stdout: 'Error: TS2304' }); + expect(facts.some(f => f === 'Errors encountered')).toBe(true); + }); + + it('should extract facts from WebSearch', () => { + const facts = extractFacts('WebSearch', { query: 'typescript tutorial' }, {}); + expect(facts).toContain('Web search: typescript tutorial'); + }); + + it('should handle null inputs', () => { + const facts = extractFacts('Read', null, null); + expect(facts).toEqual([]); + }); + }); + + describe('extractConcepts', () => { + it('should extract concepts from TypeScript files', () => { + const concepts = extractConcepts('Read', { file_path: 'src/hooks/types.ts' }); + expect(concepts).toContain('typescript'); + expect(concepts).toContain('hooks'); + }); + + it('should extract concepts from test files', () => { + const concepts = extractConcepts('Read', { file_path: 'src/__tests__/index.test.ts' }); + expect(concepts).toContain('testing'); + expect(concepts).toContain('typescript'); + }); + + it('should extract concepts from Bash commands', () => { + const concepts = extractConcepts('Bash', { command: 'npm test' }); + expect(concepts).toContain('testing'); + expect(concepts).toContain('package-management'); + }); + + it('should extract concepts from git commands', () => { + const concepts = extractConcepts('Bash', { command: 'git status' }); + expect(concepts).toContain('version-control'); + }); + + it('should extract concepts from WebSearch', () => { + const concepts = extractConcepts('WebSearch', {}); + expect(concepts).toContain('research'); + }); + + it('should extract concepts from Task', () => { + const concepts = extractConcepts('Task', { subagent_type: 'Explore' }); + expect(concepts).toContain('delegation'); + expect(concepts).toContain('Explore'); + }); + + it('should deduplicate concepts', () => { + const concepts = extractConcepts('Bash', { command: 'npm test && npm test' }); + const unique = new Set(concepts); + expect(concepts.length).toBe(unique.size); + }); + + it('should handle null inputs', () => { + const concepts = extractConcepts('Read', null); + expect(concepts).toEqual([]); + }); + }); }); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts new file mode 100644 index 0000000..16d050f --- /dev/null +++ b/src/hooks/ai-enrichment.ts @@ -0,0 +1,225 @@ +/** + * AI Enrichment for Observations + * + * Uses Claude Agent SDK (when available) to generate richer + * subtitle, narrative, facts, and concepts from tool observations. + * Falls back to template-based extraction when SDK is not available. + * + * @module @agentkits/memory/hooks/ai-enrichment + */ + +/** + * Enriched observation data from AI extraction + */ +export interface EnrichedObservation { + subtitle: string; + narrative: string; + facts: string[]; + concepts: string[]; +} + +/** + * Environment variable to enable/disable AI enrichment. + * Set AGENTKITS_AI_ENRICHMENT=true to enable, false to disable. + * When not set, defaults to auto-detect (uses AI if SDK available). + */ +const AI_ENRICHMENT_ENV_KEY = 'AGENTKITS_AI_ENRICHMENT'; + +/** Cached SDK availability */ +let _sdkAvailable: boolean | null = null; +let _queryFn: QueryFunction | null = null; + +/** Type for the SDK query function */ +export type QueryFunction = (params: { + prompt: string; + options: Record; +}) => AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + total_cost_usd?: number; + [key: string]: unknown; +}>; + +/** + * Check if AI enrichment is enabled via environment variable + * - 'true' / '1' → force enable + * - 'false' / '0' → force disable + * - not set → auto-detect (try SDK, fallback to template) + */ +function isEnvEnabled(): boolean | null { + const value = process.env[AI_ENRICHMENT_ENV_KEY]; + if (!value) return null; // auto-detect + return value === 'true' || value === '1'; +} + +/** + * Check if Claude Agent SDK is available and cache the result + */ +async function getQueryFunction(): Promise { + // Check env override first + const envEnabled = isEnvEnabled(); + if (envEnabled === false) return null; + + if (_sdkAvailable === false) return null; + if (_queryFn) return _queryFn; + + try { + const { query } = await import('@anthropic-ai/claude-agent-sdk'); + _queryFn = query as unknown as QueryFunction; + _sdkAvailable = true; + return _queryFn; + } catch { + _sdkAvailable = false; + return null; + } +} + +/** + * Build the extraction prompt for a tool observation + */ +export function buildExtractionPrompt( + toolName: string, + toolInput: string, + toolResponse: string +): string { + return `Analyze this Claude Code tool observation and extract structured insights. + +Tool: ${toolName} +Input: ${toolInput.substring(0, 2000)} +Response: ${toolResponse.substring(0, 2000)} + +Return ONLY a JSON object (no markdown, no code fences) with these fields: +{ + "subtitle": "Brief context description (5-10 words, e.g. 'Examining authentication module')", + "narrative": "One sentence explaining what happened and why (e.g. 'Read the authentication module to understand the login flow before making changes.')", + "facts": ["Array of factual observations", "e.g. 'File auth.ts contains 150 lines'", "Max 5 facts"], + "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Max 5 concepts"] +}`; +} + +/** + * Parse JSON from AI response, handling common formatting issues + */ +export function parseAIResponse(text: string): EnrichedObservation | null { + try { + // Strip markdown code fences if present + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) { + cleaned = cleaned.slice(7); + } else if (cleaned.startsWith('```')) { + cleaned = cleaned.slice(3); + } + if (cleaned.endsWith('```')) { + cleaned = cleaned.slice(0, -3); + } + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + + // Validate structure + if ( + typeof parsed.subtitle !== 'string' || + typeof parsed.narrative !== 'string' || + !Array.isArray(parsed.facts) || + !Array.isArray(parsed.concepts) + ) { + return null; + } + + return { + subtitle: parsed.subtitle.substring(0, 200), + narrative: parsed.narrative.substring(0, 500), + facts: parsed.facts.slice(0, 5).map((f: unknown) => String(f).substring(0, 200)), + concepts: parsed.concepts.slice(0, 5).map((c: unknown) => String(c).substring(0, 50)), + }; + } catch { + return null; + } +} + +/** + * Enrich an observation using Claude Agent SDK + * + * Returns enriched data if SDK is available and succeeds, + * or null to signal fallback to template-based extraction. + */ +export async function enrichWithAI( + toolName: string, + toolInput: string, + toolResponse: string, + timeoutMs: number = 15000 +): Promise { + const queryFn = await getQueryFunction(); + if (!queryFn) return null; + + try { + const prompt = buildExtractionPrompt(toolName, toolInput, toolResponse); + + // Race between AI query and timeout + const result = await Promise.race([ + executeQuery(queryFn, prompt), + new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)), + ]); + + return result; + } catch { + // AI enrichment failed, caller should use template fallback + return null; + } +} + +/** + * Execute the SDK query and extract the result + */ +async function executeQuery( + queryFn: QueryFunction, + prompt: string +): Promise { + let resultText = ''; + + const stream = queryFn({ + prompt, + options: { + model: 'haiku', + systemPrompt: 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + maxTurns: 1, + allowedTools: [], // No tools needed, just text output + }, + }); + + for await (const message of stream) { + if (message.type === 'result' && message.subtype === 'success') { + resultText = message.result || ''; + } + } + + if (!resultText) return null; + return parseAIResponse(resultText); +} + +/** + * Check if AI enrichment is available (SDK installed) + */ +export async function isAIEnrichmentAvailable(): Promise { + const queryFn = await getQueryFunction(); + return queryFn !== null; +} + +/** + * Reset cached SDK availability (for testing) + */ +export function resetAIEnrichmentCache(): void { + _sdkAvailable = null; + _queryFn = null; +} + +/** + * Inject a mock query function (for testing only) + */ +export function _setQueryFunctionForTesting(fn: QueryFunction | null): void { + _queryFn = fn; + _sdkAvailable = fn !== null; +} diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 3ae0a6a..8d30170 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -14,12 +14,20 @@ import type { Database as BetterDatabase } from 'better-sqlite3'; import { Observation, SessionRecord, + UserPrompt, + SessionSummary, MemoryContext, generateObservationId, getObservationType, generateObservationTitle, + generateObservationSubtitle, + generateObservationNarrative, + extractFilePaths, + extractFacts, + extractConcepts, truncate, } from './types.js'; +import { enrichWithAI } from './ai-enrichment.js'; /** * Memory Hook Service Configuration @@ -43,7 +51,7 @@ export interface MemoryHookServiceConfig { const DEFAULT_CONFIG: MemoryHookServiceConfig = { baseDir: '.claude/memory', - dbFilename: 'hooks.db', + dbFilename: 'memory.db', // Single DB: hooks + memories in one file maxContextObservations: 20, maxContextSessions: 5, maxResponseSize: 5000, @@ -104,7 +112,7 @@ export class MemoryHookService { // ===== Session Management ===== /** - * Initialize or get session + * Initialize or get session (idempotent) */ async initSession(sessionId: string, project: string, prompt?: string): Promise { await this.ensureInitialized(); @@ -133,6 +141,92 @@ export class MemoryHookService { }; } + // ===== User Prompt Management ===== + + /** + * Save a user prompt (tracks ALL prompts, not just the first) + */ + async saveUserPrompt(sessionId: string, project: string, promptText: string): Promise { + await this.ensureInitialized(); + + // Ensure session exists + await this.initSession(sessionId, project, promptText); + + // Get next prompt number + const promptNumber = this.getPromptNumber(sessionId) + 1; + const now = Date.now(); + + this.db!.prepare(` + INSERT OR IGNORE INTO user_prompts (session_id, prompt_number, prompt_text, created_at) + VALUES (?, ?, ?, ?) + `).run(sessionId, promptNumber, promptText, now); + + return { + id: 0, // not needed for return + sessionId, + promptNumber, + promptText, + createdAt: now, + }; + } + + /** + * Get current prompt number for a session (0 if no prompts yet) + */ + getPromptNumber(sessionId: string): number { + if (!this.db) return 0; + + const row = this.db.prepare( + 'SELECT COUNT(*) as count FROM user_prompts WHERE session_id = ?' + ).get(sessionId) as { count: number } | undefined; + + return row?.count || 0; + } + + /** + * Get all prompts for a session + */ + async getSessionPrompts(sessionId: string): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT * FROM user_prompts + WHERE session_id = ? + ORDER BY prompt_number ASC + `).all(sessionId) as Record[]; + + return rows.map(row => ({ + id: row.id as number, + sessionId: row.session_id as string, + promptNumber: row.prompt_number as number, + promptText: row.prompt_text as string, + createdAt: row.created_at as number, + })); + } + + /** + * Get recent prompts across all sessions for a project + */ + async getRecentPrompts(project: string, limit: number = 20): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT up.* FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE s.project = ? + ORDER BY up.created_at DESC + LIMIT ? + `).all(project, limit) as Record[]; + + return rows.map(row => ({ + id: row.id as number, + sessionId: row.session_id as string, + promptNumber: row.prompt_number as number, + promptText: row.prompt_text as string, + createdAt: row.created_at as number, + })); + } + /** * Get session by ID */ @@ -197,18 +291,27 @@ export class MemoryHookService { const now = Date.now(); const type = getObservationType(toolName); const title = generateObservationTitle(toolName, toolInput); + const promptNumber = this.getPromptNumber(sessionId); + const { filesRead, filesModified } = extractFilePaths(toolName, toolInput); - // Truncate large responses + // Truncate large responses (needed for both AI and template extraction) const inputStr = JSON.stringify(toolInput || {}); const responseStr = truncate( JSON.stringify(toolResponse || {}), this.config.maxResponseSize ); + // Try AI enrichment first, fall back to template-based extraction + const aiResult = await enrichWithAI(toolName, inputStr, responseStr).catch(() => null); + const subtitle = aiResult?.subtitle || generateObservationSubtitle(toolName, toolInput, toolResponse); + const narrative = aiResult?.narrative || generateObservationNarrative(toolName, toolInput, toolResponse); + const facts = aiResult?.facts || extractFacts(toolName, toolInput, toolResponse); + const concepts = aiResult?.concepts || extractConcepts(toolName, toolInput, toolResponse); + this.db!.prepare(` - INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title); + INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts)); // Update session observation count this.db!.prepare(` @@ -228,6 +331,13 @@ export class MemoryHookService { timestamp: now, type, title, + promptNumber: promptNumber || undefined, + filesRead, + filesModified, + subtitle, + narrative, + facts, + concepts, }; } @@ -281,12 +391,19 @@ export class MemoryHookService { this.config.maxContextSessions ); + const userPrompts = await this.getRecentPrompts(project, 20); + const sessionSummaries = await this.getRecentSummaries(project, 5); + // Generate markdown - const markdown = this.formatContextMarkdown(recentObservations, previousSessions, project); + const markdown = this.formatContextMarkdown( + recentObservations, previousSessions, userPrompts, sessionSummaries, project + ); return { recentObservations, previousSessions, + userPrompts, + sessionSummaries, markdown, }; } @@ -297,32 +414,75 @@ export class MemoryHookService { private formatContextMarkdown( observations: Observation[], sessions: SessionRecord[], + prompts: UserPrompt[], + summaries: SessionSummary[], project: string ): string { const lines: string[] = []; lines.push(`# Memory Context - ${project}`); lines.push(''); - lines.push('*AgentKits CPS™ - Auto-captured session memory*'); + lines.push('*AgentKits CPS - Auto-captured session memory*'); lines.push(''); - // Recent observations + // Structured summaries from previous sessions (most valuable context) + if (summaries.length > 0) { + lines.push('## Previous Session Summaries'); + lines.push(''); + + for (const summary of summaries.slice(0, 3)) { + const time = this.formatRelativeTime(summary.createdAt); + lines.push(`### Session (${time})`); + if (summary.request) { + lines.push(`**Request:** ${summary.request.substring(0, 300)}`); + } + if (summary.completed) { + lines.push(`**Completed:** ${summary.completed}`); + } + if (summary.filesModified.length > 0) { + lines.push(`**Files Modified:** ${summary.filesModified.slice(0, 10).join(', ')}`); + } + if (summary.nextSteps) { + lines.push(`**Next Steps:** ${summary.nextSteps}`); + } + lines.push(''); + } + } + + // Recent user prompts (shows what user has been asking) + if (prompts.length > 0) { + lines.push('## Recent User Prompts'); + lines.push(''); + + for (const prompt of prompts.slice(0, 10)) { + const time = this.formatRelativeTime(prompt.createdAt); + lines.push(`- (${time}) ${prompt.promptText.substring(0, 150)}${prompt.promptText.length > 150 ? '...' : ''}`); + } + lines.push(''); + } + + // Recent observations with enriched details if (observations.length > 0) { lines.push('## Recent Activity'); lines.push(''); - lines.push('| Time | Action | Details |'); - lines.push('|------|--------|---------|'); for (const obs of observations.slice(0, 10)) { const time = this.formatRelativeTime(obs.timestamp); const icon = this.getObservationIcon(obs.type); - lines.push(`| ${time} | ${icon} ${obs.toolName} | ${obs.title || ''} |`); + const detail = obs.subtitle || obs.title || obs.toolName; + lines.push(`- ${icon} **${detail}** (${time})`); + if (obs.narrative) { + lines.push(` ${obs.narrative}`); + } + if (obs.concepts && obs.concepts.length > 0) { + lines.push(` *Concepts: ${obs.concepts.join(', ')}*`); + } } lines.push(''); } - // Previous sessions - if (sessions.length > 0) { + // Previous sessions (fallback if no structured summaries) + if (summaries.length === 0 && sessions.length > 0) { lines.push('## Previous Sessions'); lines.push(''); @@ -345,7 +505,7 @@ export class MemoryHookService { } // No context available - if (observations.length === 0 && sessions.length === 0) { + if (observations.length === 0 && sessions.length === 0 && prompts.length === 0) { lines.push('*No previous session context available.*'); lines.push(''); } @@ -354,58 +514,148 @@ export class MemoryHookService { } /** - * Generate session summary from observations + * Generate session summary from observations (legacy text format) */ async generateSummary(sessionId: string): Promise { - const observations = await this.getSessionObservations(sessionId); - - if (observations.length === 0) { - return 'No activity recorded in this session.'; + const structured = await this.generateStructuredSummary(sessionId); + // Format as readable text + const parts: string[] = []; + if (structured.request) parts.push(`Request: ${structured.request}`); + if (structured.completed) parts.push(`Completed: ${structured.completed}`); + if (structured.filesModified.length > 0) { + parts.push(`Files modified: ${structured.filesModified.join(', ')}`); } + if (structured.nextSteps) parts.push(`Next: ${structured.nextSteps}`); + return parts.join('. ') || 'No activity recorded.'; + } - // Group by type - const byType: Record = {}; - const files: Set = new Set(); + /** + * Generate structured session summary from observations + prompts + */ + async generateStructuredSummary(sessionId: string): Promise> { + const observations = await this.getSessionObservations(sessionId); + const prompts = await this.getSessionPrompts(sessionId); + const session = this.getSession(sessionId); - for (const obs of observations) { - byType[obs.type] = (byType[obs.type] || 0) + 1; + // Extract file paths from observations + const filesRead: Set = new Set(); + const filesModified: Set = new Set(); + const commands: string[] = []; - // Extract file paths + for (const obs of observations) { try { const input = JSON.parse(obs.toolInput); - if (input.file_path || input.path) { - files.add(input.file_path || input.path); + const filePath = input.file_path || input.path || ''; + + if (obs.type === 'read' && filePath) { + filesRead.add(filePath); + } else if (obs.type === 'write' && filePath) { + filesModified.add(filePath); + } else if (obs.type === 'execute' && input.command) { + commands.push(input.command.substring(0, 80)); } } catch { // Ignore parse errors } } - // Build summary - const parts: string[] = []; + // Build request from user prompts + const request = prompts.length > 0 + ? prompts.map(p => `[#${p.promptNumber}] ${p.promptText.substring(0, 200)}`).join(' → ') + : session?.prompt || ''; - if (byType.write) { - parts.push(`${byType.write} file(s) modified`); - } - if (byType.read) { - parts.push(`${byType.read} file(s) read`); - } - if (byType.execute) { - parts.push(`${byType.execute} command(s) executed`); - } - if (byType.search) { - parts.push(`${byType.search} search(es)`); + // Build completed from observation summary + const byType: Record = {}; + for (const obs of observations) { + byType[obs.type] = (byType[obs.type] || 0) + 1; } + const completedParts: string[] = []; + if (byType.write) completedParts.push(`${byType.write} file(s) modified`); + if (byType.read) completedParts.push(`${byType.read} file(s) read`); + if (byType.execute) completedParts.push(`${byType.execute} command(s) executed`); + if (byType.search) completedParts.push(`${byType.search} search(es)`); - let summary = parts.join(', ') || 'Various operations performed'; + // Build notes from commands + const notes = commands.length > 0 + ? `Commands: ${commands.slice(0, 5).join('; ')}${commands.length > 5 ? ` (+${commands.length - 5} more)` : ''}` + : ''; - if (files.size > 0 && files.size <= 5) { - summary += `. Files: ${Array.from(files).join(', ')}`; - } else if (files.size > 5) { - summary += `. ${files.size} files touched.`; - } + return { + sessionId, + project: session?.project || '', + request: truncate(request, 500), + completed: completedParts.join(', ') || 'No activity recorded', + filesRead: Array.from(filesRead).slice(0, 20), + filesModified: Array.from(filesModified).slice(0, 20), + nextSteps: '', + notes, + promptNumber: prompts.length, + }; + } + + // ===== Session Summary Storage ===== + + /** + * Save structured session summary to session_summaries table + */ + async saveSessionSummary(summary: Omit): Promise { + await this.ensureInitialized(); + + const now = Date.now(); + const result = this.db!.prepare(` + INSERT INTO session_summaries + (session_id, project, request, completed, files_read, files_modified, next_steps, notes, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + summary.sessionId, + summary.project, + summary.request, + summary.completed, + JSON.stringify(summary.filesRead), + JSON.stringify(summary.filesModified), + summary.nextSteps, + summary.notes, + summary.promptNumber, + now + ); + + return { + ...summary, + id: Number(result.lastInsertRowid), + createdAt: now, + }; + } + + /** + * Get recent session summaries for a project + */ + async getRecentSummaries(project: string, limit: number = 5): Promise { + await this.ensureInitialized(); + + const rows = this.db!.prepare(` + SELECT * FROM session_summaries + WHERE project = ? + ORDER BY created_at DESC + LIMIT ? + `).all(project, limit) as Record[]; - return summary; + return rows.map(row => this.rowToSummary(row)); + } + + private rowToSummary(row: Record): SessionSummary { + return { + id: row.id as number, + sessionId: row.session_id as string, + project: row.project as string, + request: row.request as string || '', + completed: row.completed as string || '', + filesRead: JSON.parse((row.files_read as string) || '[]'), + filesModified: JSON.parse((row.files_modified as string) || '[]'), + nextSteps: row.next_steps as string || '', + notes: row.notes as string || '', + promptNumber: row.prompt_number as number || 0, + createdAt: row.created_at as number, + }; } // ===== Private Methods ===== @@ -445,14 +695,89 @@ export class MemoryHookService { timestamp INTEGER NOT NULL, type TEXT, title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]', + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + `); + + // User prompts table - tracks ALL prompts in a session + this.db.exec(` + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(session_id, prompt_number), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) `); + // Structured session summaries + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + completed TEXT, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT, + notes TEXT, + prompt_number INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + `); + + // Indexes this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_session ON observations(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_project ON observations(project)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_timestamp ON observations(timestamp)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_session ON user_prompts(session_id)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_project ON session_summaries(project)'); + + // Migration: add prompt_number to existing observations table + this.migrateSchema(); + } + + /** + * Migrate schema for existing databases (add new columns) + */ + private migrateSchema(): void { + if (!this.db) return; + + try { + const obsColumns = this.db.prepare("PRAGMA table_info(observations)").all() as Array<{ name: string }>; + const columnNames = new Set(obsColumns.map(c => c.name)); + + const migrations: Array<[string, string]> = [ + ['prompt_number', 'ALTER TABLE observations ADD COLUMN prompt_number INTEGER'], + ['files_read', "ALTER TABLE observations ADD COLUMN files_read TEXT DEFAULT '[]'"], + ['files_modified', "ALTER TABLE observations ADD COLUMN files_modified TEXT DEFAULT '[]'"], + ['subtitle', 'ALTER TABLE observations ADD COLUMN subtitle TEXT'], + ['narrative', 'ALTER TABLE observations ADD COLUMN narrative TEXT'], + ['facts', "ALTER TABLE observations ADD COLUMN facts TEXT DEFAULT '[]'"], + ['concepts', "ALTER TABLE observations ADD COLUMN concepts TEXT DEFAULT '[]'"], + ]; + + for (const [column, sql] of migrations) { + if (!columnNames.has(column)) { + this.db.exec(sql); + } + } + } catch { + // Ignore migration errors on fresh databases + } } private rowToSession(row: Record): SessionRecord { @@ -481,6 +806,13 @@ export class MemoryHookService { timestamp: row.timestamp as number, type: row.type as Observation['type'], title: row.title as string | undefined, + promptNumber: row.prompt_number as number | undefined, + filesRead: JSON.parse((row.files_read as string) || '[]'), + filesModified: JSON.parse((row.files_modified as string) || '[]'), + subtitle: row.subtitle as string | undefined, + narrative: row.narrative as string | undefined, + facts: JSON.parse((row.facts as string) || '[]'), + concepts: JSON.parse((row.concepts as string) || '[]'), }; } diff --git a/src/hooks/session-init.ts b/src/hooks/session-init.ts index 8f09a9c..fe61e86 100644 --- a/src/hooks/session-init.ts +++ b/src/hooks/session-init.ts @@ -53,6 +53,15 @@ export class SessionInitHook implements EventHandler { input.prompt ); + // Save every user prompt (not just the first) + if (input.prompt) { + await this.service.saveUserPrompt( + input.sessionId, + input.project, + input.prompt + ); + } + return { continue: true, suppressOutput: true, diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index f73425b..77a366e 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -56,11 +56,15 @@ export class SummarizeHook implements EventHandler { }; } - // Generate summary from observations - const summary = await this.service.generateSummary(input.sessionId); + // Generate structured summary from observations + prompts + const structured = await this.service.generateStructuredSummary(input.sessionId); - // Complete the session with summary - await this.service.completeSession(input.sessionId, summary); + // Save structured summary to session_summaries table (same DB as memories) + await this.service.saveSessionSummary(structured); + + // Complete the session with text summary (legacy field) + const textSummary = await this.service.generateSummary(input.sessionId); + await this.service.completeSession(input.sessionId, textSummary); // Shutdown service await this.service.shutdown(); diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 2c5ed05..2f5b741 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -163,6 +163,27 @@ export interface Observation { /** Brief title (auto-generated) */ title?: string; + + /** Which prompt number this observation belongs to */ + promptNumber?: number; + + /** Files read in this observation (auto-extracted) */ + filesRead?: string[]; + + /** Files modified in this observation (auto-extracted) */ + filesModified?: string[]; + + /** Brief subtitle describing the action context */ + subtitle?: string; + + /** Narrative explanation of what happened */ + narrative?: string; + + /** Extracted facts from the observation */ + facts?: string[]; + + /** Extracted concepts/topics */ + concepts?: string[]; } /** @@ -207,6 +228,64 @@ export interface SessionRecord { status: 'active' | 'completed' | 'abandoned'; } +/** + * User prompt record - tracks ALL prompts in a session + */ +export interface UserPrompt { + /** Database ID */ + id: number; + + /** Claude's session ID */ + sessionId: string; + + /** Prompt number within session (1, 2, 3...) */ + promptNumber: number; + + /** User's prompt text */ + promptText: string; + + /** Timestamp */ + createdAt: number; +} + +/** + * Structured session summary + */ +export interface SessionSummary { + /** Database ID */ + id: number; + + /** Claude's session ID */ + sessionId: string; + + /** Project name */ + project: string; + + /** What user requested */ + request: string; + + /** What was completed */ + completed: string; + + /** Files read during session */ + filesRead: string[]; + + /** Files modified during session */ + filesModified: string[]; + + /** Remaining work / next steps */ + nextSteps: string; + + /** Additional notes */ + notes: string; + + /** Which prompt triggered this summary */ + promptNumber: number; + + /** Timestamp */ + createdAt: number; +} + // ===== Context Types ===== /** @@ -219,6 +298,12 @@ export interface MemoryContext { /** Previous sessions */ previousSessions: SessionRecord[]; + /** User prompts from recent sessions */ + userPrompts: UserPrompt[]; + + /** Structured session summaries */ + sessionSummaries: SessionSummary[]; + /** Project-specific patterns */ patterns?: string[]; @@ -264,6 +349,32 @@ export function getObservationType(toolName: string): ObservationType { return 'other'; } +/** + * Extract file paths from tool input, classified as read or modified + */ +export function extractFilePaths(toolName: string, toolInput: unknown): { filesRead: string[]; filesModified: string[] } { + const filesRead: string[] = []; + const filesModified: string[] = []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + + if (!filePath) return { filesRead, filesModified }; + + const type = getObservationType(toolName); + if (type === 'write') { + filesModified.push(filePath); + } else if (type === 'read') { + filesRead.push(filePath); + } + } catch { + // Ignore parse errors + } + + return { filesRead, filesModified }; +} + /** * Generate observation title from tool usage */ @@ -299,6 +410,225 @@ export function generateObservationTitle(toolName: string, toolInput: unknown): } } +/** + * Generate observation subtitle from tool usage context + */ +export function generateObservationSubtitle(toolName: string, toolInput: unknown, _toolResponse?: unknown): string { + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + const fileName = filePath ? filePath.split(/[/\\]/).pop() : ''; + + switch (toolName) { + case 'Read': + return fileName ? `Examining ${fileName}` : 'Reading file contents'; + case 'Write': + return fileName ? `Creating/updating ${fileName}` : 'Writing file'; + case 'Edit': + return fileName ? `Modifying ${fileName}` : 'Editing file'; + case 'Bash': { + const cmd = (input?.command || '').split(/\s+/)[0]; + const cmdMap: Record = { + npm: 'Running npm command', node: 'Running Node.js', git: 'Git operation', + cd: 'Changing directory', ls: 'Listing files', mkdir: 'Creating directory', + rm: 'Removing files', cp: 'Copying files', mv: 'Moving files', + docker: 'Docker operation', python: 'Running Python', cargo: 'Cargo operation', + }; + return cmdMap[cmd] || `Executing ${cmd || 'command'}`; + } + case 'Glob': + return `Searching for ${input?.pattern || 'files'} pattern`; + case 'Grep': + return `Searching code for "${input?.pattern || 'pattern'}"`; + case 'Task': + return `Delegating to ${input?.subagent_type || 'sub-agent'}`; + case 'WebSearch': + return `Researching: ${(input?.query || '').substring(0, 60)}`; + case 'WebFetch': + return `Fetching web content`; + default: + return `Using ${toolName} tool`; + } + } catch { + return `Using ${toolName}`; + } +} + +/** + * Generate observation narrative from tool usage + */ +export function generateObservationNarrative( + toolName: string, toolInput: unknown, _toolResponse?: unknown +): string { + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = input?.file_path || input?.path || ''; + + switch (toolName) { + case 'Read': + return `Read the contents of ${filePath || 'a file'} to understand the existing code structure.`; + case 'Write': + return `Wrote ${filePath || 'a file'} with new or updated content.`; + case 'Edit': { + const oldStr = input?.old_string ? `"${input.old_string.substring(0, 40)}..."` : 'code'; + return `Edited ${filePath || 'a file'}, replacing ${oldStr} with updated content.`; + } + case 'Bash': { + const cmd = input?.command || ''; + if (cmd.startsWith('npm test') || cmd.startsWith('npx vitest')) + return `Ran tests to verify changes: \`${cmd.substring(0, 80)}\`.`; + if (cmd.startsWith('npm run build') || cmd.startsWith('tsc')) + return `Built the project to check for compilation errors.`; + if (cmd.startsWith('git ')) + return `Performed git operation: \`${cmd.substring(0, 80)}\`.`; + return `Executed command: \`${cmd.substring(0, 80)}\`.`; + } + case 'Glob': + return `Searched the filesystem for files matching pattern "${input?.pattern || ''}".`; + case 'Grep': + return `Searched code for pattern "${input?.pattern || ''}"${input?.path ? ` in ${input.path}` : ''}.`; + case 'Task': + return `Delegated work to a ${input?.subagent_type || 'sub'}-agent: ${input?.description || 'task'}.`; + case 'WebSearch': + return `Searched the web for: ${input?.query || 'information'}.`; + case 'WebFetch': + return `Fetched content from ${input?.url || 'a URL'}.`; + default: + return `Used ${toolName} tool.`; + } + } catch { + return `Used ${toolName} tool.`; + } +} + +/** + * Extract facts from tool input/response + */ +export function extractFacts(toolName: string, toolInput: unknown, toolResponse: unknown): string[] { + const facts: string[] = []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const response = typeof toolResponse === 'string' ? JSON.parse(toolResponse) : toolResponse; + const filePath = input?.file_path || input?.path || ''; + + switch (toolName) { + case 'Read': + if (filePath) facts.push(`File read: ${filePath}`); + break; + case 'Write': + if (filePath) facts.push(`File created/updated: ${filePath}`); + break; + case 'Edit': + if (filePath) facts.push(`File modified: ${filePath}`); + if (input?.old_string) facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || 'file'}`); + break; + case 'Bash': { + const cmd = input?.command || ''; + facts.push(`Command executed: ${cmd.substring(0, 100)}`); + // Extract test results + const stdout = response?.stdout || response?.output || ''; + if (typeof stdout === 'string') { + if (stdout.includes('passed') || stdout.includes('✓')) facts.push('Tests passed'); + if (stdout.includes('failed') || stdout.includes('✗')) facts.push('Tests failed'); + if (stdout.includes('error') || stdout.includes('Error')) facts.push('Errors encountered'); + } + break; + } + case 'Glob': + if (input?.pattern) facts.push(`Pattern searched: ${input.pattern}`); + break; + case 'Grep': + if (input?.pattern) facts.push(`Code pattern searched: ${input.pattern}`); + if (input?.path) facts.push(`Search scope: ${input.path}`); + break; + case 'WebSearch': + if (input?.query) facts.push(`Web search: ${input.query}`); + break; + case 'WebFetch': + if (input?.url) facts.push(`URL fetched: ${input.url}`); + break; + case 'Task': + if (input?.description) facts.push(`Sub-task: ${input.description}`); + if (input?.subagent_type) facts.push(`Agent type: ${input.subagent_type}`); + break; + } + } catch { + // Ignore parse errors + } + + return facts; +} + +/** + * Extract concepts/topics from tool usage + */ +export function extractConcepts(toolName: string, toolInput: unknown, _toolResponse?: unknown): string[] { + const concepts: Set = new Set(); + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const filePath = (input?.file_path || input?.path || '') as string; + + // Extract concepts from file paths + if (filePath) { + // Directory-based concepts + const parts = filePath.split(/[/\\]/); + for (const part of parts) { + if (['src', 'lib', 'dist', 'node_modules', '.', '..'].includes(part)) continue; + if (part.includes('.')) { + // File extension concepts + const ext = part.split('.').pop(); + const extMap: Record = { + ts: 'typescript', tsx: 'react', js: 'javascript', jsx: 'react', + py: 'python', rs: 'rust', go: 'golang', css: 'styling', scss: 'styling', + html: 'html', json: 'configuration', yaml: 'configuration', yml: 'configuration', + md: 'documentation', test: 'testing', spec: 'testing', sql: 'database', + }; + if (ext && extMap[ext]) concepts.add(extMap[ext]); + } + // Directory-based concepts + const dirMap: Record = { + tests: 'testing', __tests__: 'testing', test: 'testing', spec: 'testing', + hooks: 'hooks', api: 'api', auth: 'authentication', db: 'database', + components: 'components', pages: 'pages', routes: 'routing', utils: 'utilities', + services: 'services', middleware: 'middleware', models: 'models', types: 'types', + cli: 'cli', config: 'configuration', migrations: 'database', schemas: 'schemas', + }; + if (dirMap[part]) concepts.add(dirMap[part]); + } + } + + // Tool-based concepts + switch (toolName) { + case 'Bash': { + const cmd = (input?.command || '') as string; + if (cmd.includes('test') || cmd.includes('vitest') || cmd.includes('jest')) concepts.add('testing'); + if (cmd.includes('build') || cmd.includes('tsc')) concepts.add('build'); + if (cmd.includes('git')) concepts.add('version-control'); + if (cmd.includes('npm') || cmd.includes('yarn') || cmd.includes('pnpm')) concepts.add('package-management'); + if (cmd.includes('docker')) concepts.add('containerization'); + if (cmd.includes('lint') || cmd.includes('eslint')) concepts.add('linting'); + break; + } + case 'WebSearch': + concepts.add('research'); + break; + case 'WebFetch': + concepts.add('web-content'); + break; + case 'Task': + concepts.add('delegation'); + if (input?.subagent_type) concepts.add(input.subagent_type as string); + break; + } + } catch { + // Ignore parse errors + } + + return Array.from(concepts); +} + /** * Truncate string to max length */ diff --git a/vitest.config.ts b/vitest.config.ts index 42336a4..7d4fa5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,9 +8,10 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - exclude: ['node_modules', 'dist', '**/*.test.ts'], + exclude: ['node_modules', 'dist', '**/*.test.ts', 'src/__tests__/setup.ts', 'src/embeddings/index.ts', 'src/search/index.ts'], }, testTimeout: 30000, hookTimeout: 30000, + setupFiles: ['./src/__tests__/setup.ts'], }, }); From b90604d56920ba63caaedcaa593c8661923c8ed6 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Tue, 3 Feb 2026 20:13:58 +0900 Subject: [PATCH 02/21] feat: Enhance memory management with new tools and user guidance - Implemented __IMPORTANT meta-tool to guide users on memory workflow and usage. - Added memory_delete and memory_update tools for managing existing memories. - Introduced advanced filtering options (dateStart, dateEnd, orderBy) in memory_search. - Enhanced context handling in ContextHook to provide better user guidance when no previous sessions exist. - Created UserMessageHook to display memory status to users without altering conversation context. - Improved observation handling in ObservationHook with AI enrichment in a fire-and-forget manner. - Updated MemoryHookService to support asynchronous AI enrichment of observations. - Enhanced tests to cover new tools and features, ensuring robust functionality. --- .mcp.json | 8 + package.json | 2 +- .../__tests__/embedding-subprocess.test.ts | 355 ++++++++++++++++++ src/embeddings/embedding-subprocess.ts | 305 +++++++++++++++ src/embeddings/embedding-worker.ts | 107 ++++++ src/embeddings/index.ts | 2 + src/hooks/__tests__/ai-enrichment.test.ts | 28 ++ src/hooks/__tests__/handlers.test.ts | 159 +++++++- src/hooks/__tests__/integration.test.ts | 21 +- src/hooks/__tests__/service.test.ts | 152 ++++++++ src/hooks/ai-enrichment.ts | 13 + src/hooks/cli.ts | 23 +- src/hooks/context.ts | 42 ++- src/hooks/index.ts | 1 + src/hooks/observation.ts | 47 ++- src/hooks/service.ts | 67 +++- src/hooks/user-message.ts | 103 +++++ src/mcp/__tests__/server.test.ts | 154 +++++++- src/mcp/server.ts | 268 +++++++++++-- src/mcp/tools.ts | 86 ++++- src/mcp/types.ts | 21 +- 21 files changed, 1885 insertions(+), 79 deletions(-) create mode 100644 .mcp.json create mode 100644 src/embeddings/__tests__/embedding-subprocess.test.ts create mode 100644 src/embeddings/embedding-subprocess.ts create mode 100644 src/embeddings/embedding-worker.ts create mode 100644 src/hooks/user-message.ts diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..b34e4bb --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "memory": { + "command": "node", + "args": ["dist/mcp/server.js"] + } + } +} diff --git a/package.json b/package.json index 8229011..e02a2dc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "agentkits-memory-setup": "./dist/cli/setup.js" }, "scripts": { - "build": "tsc", + "build": "tsc && chmod +x dist/hooks/cli.js dist/mcp/server.js dist/cli/viewer.js dist/cli/web-viewer.js dist/cli/save.js dist/cli/setup.js", "test": "vitest run", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", diff --git a/src/embeddings/__tests__/embedding-subprocess.test.ts b/src/embeddings/__tests__/embedding-subprocess.test.ts new file mode 100644 index 0000000..c892eed --- /dev/null +++ b/src/embeddings/__tests__/embedding-subprocess.test.ts @@ -0,0 +1,355 @@ +/** + * Embedding Subprocess Tests + * + * Tests for the subprocess-based embedding service. + * Uses mock provider to avoid downloading the real model. + * + * @module @aitytech/agentkits-memory/embeddings/__tests__/embedding-subprocess.test + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { EmbeddingSubprocess } from '../embedding-subprocess.js'; +import { fork } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +// Mock child_process.fork to avoid spawning real processes +vi.mock('node:child_process', () => ({ + fork: vi.fn(), +})); + +const mockFork = vi.mocked(fork); + +/** + * Create a mock child process that emits events + */ +function createMockChild() { + const child = new EventEmitter() as EventEmitter & { + send: ReturnType; + kill: ReturnType; + }; + child.send = vi.fn(); + child.kill = vi.fn(); + return child; +} + +describe('EmbeddingSubprocess', () => { + let subprocess: EmbeddingSubprocess; + let mockChild: ReturnType; + + afterEach(async () => { + if (subprocess) { + await subprocess.shutdown(); + subprocess = undefined as any; + } + vi.restoreAllMocks(); + // Re-mock fork after restore + mockFork.mockReset(); + }); + + describe('spawn', () => { + it('should fork the embedding worker process', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test-cache' }); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + expect(mockFork).toHaveBeenCalledWith( + expect.stringContaining('embedding-worker.js'), + ['/tmp/test-cache'], + expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }), + ); + }); + + it('should not double-spawn', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + }); + }); + + describe('ready state', () => { + it('should mark as ready when worker sends ready message', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + expect(subprocess.isReady()).toBe(false); + + // Emit ready — the subprocess registered listener on the fork result + mockChild.emit('message', { type: 'ready' }); + + expect(subprocess.isReady()).toBe(true); + }); + + it('should verify fork returns our mock', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + // Verify fork was called and our mock has listeners + expect(mockChild.listenerCount('message')).toBeGreaterThan(0); + }); + }); + + describe('embed', () => { + it('should send embed request when worker is ready', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const embedPromise = subprocess.embed('test text'); + + // Worker should receive the request + expect(mockChild.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'embed', + text: 'test text', + }), + ); + + // Simulate worker response + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'embed_result', + id: callArgs.id, + embedding: Array.from(new Float32Array(384).fill(0.1)), + timeMs: 50, + cached: false, + }); + + const result = await embedPromise; + + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + + it('should queue requests when worker is not ready', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + // Embed before ready — should be queued + const embedPromise = subprocess.embed('queued text'); + + // No message sent yet (queued) + expect(mockChild.send).not.toHaveBeenCalled(); + + // Worker becomes ready — queue should drain + mockChild.emit('message', { type: 'ready' }); + + expect(mockChild.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'embed', + text: 'queued text', + }), + ); + + // Simulate response + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'embed_result', + id: callArgs.id, + embedding: Array.from(new Float32Array(384).fill(0.2)), + timeMs: 30, + cached: false, + }); + + const result = await embedPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + + it('should fall back to mock embedding on request timeout', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 50, // 50ms timeout for test + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result = await subprocess.embed('timeout text'); + + // Should get a mock embedding (non-zero, deterministic) + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + // Mock embeddings are normalized, so magnitude should be ~1 + let magnitude = 0; + for (const v of result) magnitude += v * v; + expect(Math.sqrt(magnitude)).toBeCloseTo(1, 1); + }); + + it('should fall back to mock on worker error response', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const embedPromise = subprocess.embed('error text'); + + const callArgs = mockChild.send.mock.calls[0][0] as { id: string }; + mockChild.emit('message', { + type: 'error', + id: callArgs.id, + message: 'Embed failed', + }); + + const result = await embedPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(384); + }); + }); + + describe('getGenerator', () => { + it('should return an EmbeddingGenerator function', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + const generator = subprocess.getGenerator(); + + expect(typeof generator).toBe('function'); + }); + }); + + describe('respawn', () => { + it('should respawn worker on unexpected exit', () => { + mockChild = createMockChild(); + const secondChild = createMockChild(); + mockFork.mockReturnValueOnce(mockChild as any).mockReturnValueOnce(secondChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + expect(mockFork).toHaveBeenCalledTimes(1); + + // Simulate crash + mockChild.emit('exit', 1); + + expect(mockFork).toHaveBeenCalledTimes(2); + }); + + it('should stop respawning after max attempts', () => { + const children = Array.from({ length: 4 }, () => createMockChild()); + for (const c of children) mockFork.mockReturnValueOnce(c as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); // spawn #1 + + children[0].emit('exit', 1); // respawn #1 + children[1].emit('exit', 1); // respawn #2 + children[2].emit('exit', 1); // should NOT respawn (max 2 respawns) + + expect(mockFork).toHaveBeenCalledTimes(3); // initial + 2 respawns + }); + }); + + describe('shutdown', () => { + it('should clean up on shutdown', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + + mockChild.emit('message', { type: 'ready' }); + + expect(subprocess.isReady()).toBe(true); + + await subprocess.shutdown(); + + expect(subprocess.isReady()).toBe(false); + }); + + it('should not respawn after shutdown', () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ cacheDir: '/tmp/test' }); + subprocess.spawn(); + subprocess.shutdown(); + + mockChild.emit('exit', 0); + + // Should not have spawned a second time + expect(mockFork).toHaveBeenCalledTimes(1); + }); + }); + + describe('mock embedding consistency', () => { + it('should produce deterministic mock embeddings for same text', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result1 = await subprocess.embed('deterministic test'); + const result2 = await subprocess.embed('deterministic test'); + + expect(Array.from(result1)).toEqual(Array.from(result2)); + }); + + it('should produce different mock embeddings for different text', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result1 = await subprocess.embed('text one'); + const result2 = await subprocess.embed('text two'); + + expect(Array.from(result1)).not.toEqual(Array.from(result2)); + }); + }); + + describe('custom dimensions', () => { + it('should use custom dimensions for mock fallback', async () => { + mockChild = createMockChild(); + mockFork.mockReturnValue(mockChild as any); + + subprocess = new EmbeddingSubprocess({ + cacheDir: '/tmp/test', + dimensions: 128, + requestTimeout: 10, + }); + subprocess.spawn(); + mockChild.emit('message', { type: 'ready' }); + + const result = await subprocess.embed('custom dims'); + + expect(result.length).toBe(128); + }); + }); +}); diff --git a/src/embeddings/embedding-subprocess.ts b/src/embeddings/embedding-subprocess.ts new file mode 100644 index 0000000..94f824c --- /dev/null +++ b/src/embeddings/embedding-subprocess.ts @@ -0,0 +1,305 @@ +/** + * Embedding Subprocess Client + * + * Manages a child process that runs the embedding model. + * MCP server uses this for non-blocking embeddings — the server + * starts instantly while the model loads in the background. + * + * Provides the standard EmbeddingGenerator interface. + * Falls back to mock embeddings on timeout or worker failure. + * + * @module @aitytech/agentkits-memory/embeddings/embedding-subprocess + */ + +import { fork, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; +import type { EmbeddingGenerator } from '../types.js'; + +/** + * Configuration for the embedding subprocess + */ +export interface EmbeddingSubprocessConfig { + /** Cache directory for the embedding model */ + cacheDir: string; + /** Vector dimensions (default: 384) */ + dimensions?: number; + /** Timeout for worker initialization in ms (default: 60000) */ + initTimeout?: number; + /** Timeout for individual embed requests in ms (default: 30000) */ + requestTimeout?: number; +} + +// IPC message types +interface EmbedResultMessage { + type: 'embed_result'; + id: string; + embedding: number[]; + timeMs: number; + cached: boolean; +} + +interface ReadyMessage { + type: 'ready'; +} + +interface ErrorMessage { + type: 'error'; + id?: string; + message: string; +} + +type WorkerMessage = EmbedResultMessage | ReadyMessage | ErrorMessage; + +interface PendingRequest { + resolve: (embedding: Float32Array) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +/** + * Deterministic mock embedding for fallback. + * Matches the mock in LocalEmbeddingsService for consistency. + */ +function createMockEmbedding(text: string, dimensions: number): Float32Array { + const embedding = new Float32Array(dimensions); + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) - hash) + text.charCodeAt(i); + hash = hash & hash; + } + for (let i = 0; i < dimensions; i++) { + hash = ((hash << 5) - hash) + i; + hash = hash & hash; + embedding[i] = (hash % 1000) / 1000 - 0.5; + } + let norm = 0; + for (let i = 0; i < dimensions; i++) { + norm += embedding[i] * embedding[i]; + } + norm = Math.sqrt(norm); + if (norm > 0) { + for (let i = 0; i < dimensions; i++) { + embedding[i] /= norm; + } + } + return embedding; +} + +/** + * Embedding subprocess client. + * + * Spawns a child process that loads the embedding model. + * Requests are queued until the worker is ready. + * Falls back to mock embeddings on timeout. + */ +export class EmbeddingSubprocess { + private child: ChildProcess | null = null; + private ready = false; + private pending = new Map(); + private queue: Array<{ id: string; text: string }> = []; + private requestCounter = 0; + private respawnCount = 0; + private shuttingDown = false; + private initTimer: ReturnType | null = null; + + private readonly dimensions: number; + private readonly cacheDir: string; + private readonly initTimeout: number; + private readonly requestTimeout: number; + private readonly maxRespawns = 2; + + constructor(config: EmbeddingSubprocessConfig) { + this.cacheDir = config.cacheDir; + this.dimensions = config.dimensions ?? 384; + this.initTimeout = config.initTimeout ?? 60_000; + this.requestTimeout = config.requestTimeout ?? 30_000; + } + + /** + * Spawn the embedding worker process. Returns immediately. + * The worker loads the model in the background. + */ + spawn(): void { + if (this.child || this.shuttingDown) return; + + const workerPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'embedding-worker.js', + ); + + this.child = fork(workerPath, [this.cacheDir], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + this.child.on('message', (msg: WorkerMessage) => { + this.handleMessage(msg); + }); + + this.child.on('exit', () => { + this.child = null; + this.ready = false; + + if (!this.shuttingDown && this.respawnCount < this.maxRespawns) { + this.respawnCount++; + this.spawn(); + } else { + // Worker dead, resolve all pending with mock + this.resolveAllWithMock('Worker exited'); + } + }); + + this.child.on('error', () => { + // Handled by 'exit' event + }); + + // Init timeout — if worker doesn't become ready, fall back to mock + this.initTimer = setTimeout(() => { + if (!this.ready) { + this.resolveAllWithMock('Init timeout'); + // Keep the worker alive — it may still become ready later + } + }, this.initTimeout); + } + + /** + * Handle IPC message from worker + */ + private handleMessage(msg: WorkerMessage): void { + if (msg.type === 'ready') { + this.ready = true; + if (this.initTimer) { + clearTimeout(this.initTimer); + this.initTimer = null; + } + this.drainQueue(); + return; + } + + if (msg.type === 'embed_result') { + const req = this.pending.get(msg.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(msg.id); + req.resolve(new Float32Array(msg.embedding)); + } + return; + } + + if (msg.type === 'error' && msg.id) { + const req = this.pending.get(msg.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(msg.id); + // Fall back to mock instead of rejecting + req.resolve(createMockEmbedding(msg.id, this.dimensions)); + } + } + } + + /** + * Send all queued requests to the worker + */ + private drainQueue(): void { + while (this.queue.length > 0) { + const item = this.queue.shift()!; + this.child?.send({ type: 'embed', id: item.id, text: item.text }); + } + } + + /** + * Resolve all pending and queued requests with mock embeddings + */ + private resolveAllWithMock(_reason: string): void { + // Resolve queued items + for (const item of this.queue) { + const req = this.pending.get(item.id); + if (req) { + clearTimeout(req.timer); + this.pending.delete(item.id); + req.resolve(createMockEmbedding(item.text, this.dimensions)); + } + } + this.queue = []; + + // Resolve remaining pending + for (const [id, req] of this.pending) { + clearTimeout(req.timer); + req.resolve(createMockEmbedding(id, this.dimensions)); + } + this.pending.clear(); + } + + /** + * Generate embedding for text. + * Queues the request if worker is not ready yet. + * Falls back to mock on timeout. + */ + async embed(text: string): Promise { + const id = String(this.requestCounter++); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id); + resolve(createMockEmbedding(text, this.dimensions)); + }, this.requestTimeout); + + this.pending.set(id, { resolve, reject: () => {}, timer }); + + if (this.ready && this.child) { + this.child.send({ type: 'embed', id, text }); + } else { + this.queue.push({ id, text }); + } + }); + } + + /** + * Get an EmbeddingGenerator function compatible with ProjectMemoryService + */ + getGenerator(): EmbeddingGenerator { + return (content: string) => this.embed(content); + } + + /** + * Whether the worker is ready to process requests + */ + isReady(): boolean { + return this.ready; + } + + /** + * Shutdown the worker gracefully + */ + async shutdown(): Promise { + this.shuttingDown = true; + + if (this.initTimer) { + clearTimeout(this.initTimer); + this.initTimer = null; + } + + // Clear pending requests + for (const [, req] of this.pending) { + clearTimeout(req.timer); + } + this.pending.clear(); + this.queue = []; + + if (this.child) { + try { + this.child.send({ type: 'shutdown' }); + } catch { + // IPC may already be closed + } + // Force kill after grace period + const child = this.child; + setTimeout(() => { + try { child.kill(); } catch { /* already dead */ } + }, 1000); + this.child = null; + } + + this.ready = false; + } +} diff --git a/src/embeddings/embedding-worker.ts b/src/embeddings/embedding-worker.ts new file mode 100644 index 0000000..8a8d79d --- /dev/null +++ b/src/embeddings/embedding-worker.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Embedding Worker Process + * + * Runs as a child process spawned by EmbeddingSubprocess. + * Loads the ML model once and handles embed requests via Node IPC. + * + * Usage: fork('embedding-worker.js', [cacheDir]) + * + * @module @aitytech/agentkits-memory/embeddings/embedding-worker + */ + +import { LocalEmbeddingsService } from './local-embeddings.js'; + +// IPC message types (worker → parent) +interface ReadyMessage { + type: 'ready'; +} + +interface EmbedResultMessage { + type: 'embed_result'; + id: string; + embedding: number[]; + timeMs: number; + cached: boolean; +} + +interface ErrorMessage { + type: 'error'; + id?: string; + message: string; +} + +// IPC message types (parent → worker) +interface EmbedRequest { + type: 'embed'; + id: string; + text: string; +} + +interface ShutdownRequest { + type: 'shutdown'; +} + +type ParentMessage = EmbedRequest | ShutdownRequest; +type WorkerResponse = ReadyMessage | EmbedResultMessage | ErrorMessage; + +function send(msg: WorkerResponse): void { + if (process.send) { + process.send(msg); + } +} + +async function main(): Promise { + const cacheDir = process.argv[2] || ''; + + const service = new LocalEmbeddingsService({ + cacheDir, + cacheEnabled: true, + }); + + try { + await service.initialize(); + send({ type: 'ready' }); + } catch (error) { + send({ + type: 'error', + message: `Init failed: ${error instanceof Error ? error.message : String(error)}`, + }); + // Still send ready — LocalEmbeddingsService falls back to mock internally + send({ type: 'ready' }); + } + + process.on('message', async (msg: ParentMessage) => { + if (msg.type === 'shutdown') { + await service.shutdown(); + process.exit(0); + } + + if (msg.type === 'embed') { + try { + const result = await service.embed(msg.text); + send({ + type: 'embed_result', + id: msg.id, + embedding: Array.from(result.embedding), + timeMs: result.timeMs, + cached: result.cached, + }); + } catch (error) { + send({ + type: 'error', + id: msg.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + }); +} + +main().catch((error) => { + send({ + type: 'error', + message: `Worker fatal: ${error instanceof Error ? error.message : String(error)}`, + }); + process.exit(1); +}); diff --git a/src/embeddings/index.ts b/src/embeddings/index.ts index 145bfdf..cfd000f 100644 --- a/src/embeddings/index.ts +++ b/src/embeddings/index.ts @@ -18,3 +18,5 @@ export { } from './local-embeddings.js'; export { PersistentEmbeddingCache, createPersistentEmbeddingCache } from './embedding-cache.js'; + +export { EmbeddingSubprocess, type EmbeddingSubprocessConfig } from './embedding-subprocess.js'; diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index a0e7461..b8f6ba8 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { enrichWithAI, isAIEnrichmentAvailable, + isAIEnrichmentEnabled, resetAIEnrichmentCache, parseAIResponse, buildExtractionPrompt, @@ -81,6 +82,33 @@ describe('AI Enrichment Module', () => { }); }); + describe('isAIEnrichmentEnabled (sync)', () => { + it('should return false when env=false', () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + expect(isAIEnrichmentEnabled()).toBe(false); + }); + + it('should return false when env=0', () => { + process.env.AGENTKITS_AI_ENRICHMENT = '0'; + expect(isAIEnrichmentEnabled()).toBe(false); + }); + + it('should return true when env=true', () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + + it('should return true when env=1', () => { + process.env.AGENTKITS_AI_ENRICHMENT = '1'; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + + it('should return true when env not set (auto-detect optimistic)', () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + expect(isAIEnrichmentEnabled()).toBe(true); + }); + }); + describe('isAIEnrichmentAvailable', () => { it('should return false when env disabled', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'false'; diff --git a/src/hooks/__tests__/handlers.test.ts b/src/hooks/__tests__/handlers.test.ts index 0982558..350951f 100644 --- a/src/hooks/__tests__/handlers.test.ts +++ b/src/hooks/__tests__/handlers.test.ts @@ -13,6 +13,7 @@ import { ContextHook, createContextHook } from '../context.js'; import { SessionInitHook, createSessionInitHook } from '../session-init.js'; import { ObservationHook, createObservationHook } from '../observation.js'; import { SummarizeHook, createSummarizeHook } from '../summarize.js'; +import { UserMessageHook, createUserMessageHook } from '../user-message.js'; const TEST_DIR = path.join(process.cwd(), '.test-hook-handlers'); @@ -74,15 +75,19 @@ describe('Hook Handlers', () => { }); describe('ContextHook', () => { - it('should return no context for new project', async () => { + it('should return empty-state guidance for new project', async () => { const hook = trackHook(createContextHook(TEST_DIR)); const input = createTestInput(); const result = await hook.execute(input); expect(result.continue).toBe(true); - expect(result.suppressOutput).toBe(true); - expect(result.additionalContext).toBeUndefined(); + expect(result.suppressOutput).toBe(false); + // Should inject guidance even on empty state + expect(result.additionalContext).toBeDefined(); + expect(result.additionalContext).toContain('Memory tools available'); + expect(result.additionalContext).toContain('memory_save'); + expect(result.additionalContext).toContain('Do NOT call'); }); it('should return context with prompts and summaries', async () => { @@ -125,6 +130,9 @@ describe('Hook Handlers', () => { expect(result.additionalContext).toContain('Recent User Prompts'); expect(result.additionalContext).toContain('Add tests'); expect(result.additionalContext).toContain('auth.ts'); + // Should include tool-usage instructions + expect(result.additionalContext).toContain('Memory tools available'); + expect(result.additionalContext).toContain('memory_search'); }); it('should handle errors gracefully', async () => { @@ -302,8 +310,8 @@ describe('Hook Handlers', () => { const input = createTestInput({ sessionId: 'new-session', toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test' }, }); const result = await hook.execute(input); @@ -320,12 +328,91 @@ describe('Hook Handlers', () => { expect(session).not.toBeNull(); }); + it('should return fast with template data (no AI blocking)', async () => { + // Initialize session + const initHook = trackHook(createSessionInitHook(TEST_DIR)); + await initHook.execute(createTestInput({ prompt: 'Test task' })); + await initHook.shutdown(); + + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Read', + toolInput: { file_path: '/path/to/auth.ts' }, + toolResponse: { content: 'export class Auth {}' }, + }); + + // Measure execution time — should be fast (<500ms) since AI is fire-and-forget + const start = Date.now(); + const result = await hook.execute(input); + const elapsed = Date.now() - start; + await hook.shutdown(); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + // Should complete quickly (template-only, no AI blocking) + expect(elapsed).toBeLessThan(2000); + + // Verify template data was stored immediately + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(1); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].subtitle.length).toBeGreaterThan(0); + expect(observations[0].narrative).toBeDefined(); + expect(observations[0].narrative.length).toBeGreaterThan(0); + }); + + it('should not spawn enrichment when AGENTKITS_AI_ENRICHMENT=false', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + + try { + const initHook = trackHook(createSessionInitHook(TEST_DIR)); + await initHook.execute(createTestInput({ prompt: 'Test' })); + await initHook.shutdown(); + + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Write', + toolInput: { file_path: 'test.ts' }, + toolResponse: {}, + }); + + const result = await hook.execute(input); + await hook.shutdown(); + + // Should still store observation successfully + expect(result.continue).toBe(true); + + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(1); + expect(observations[0].toolName).toBe('Write'); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + it('should handle errors gracefully', async () => { const hook = new ObservationHook({ initialize: async () => { throw new Error('Test error'); }, } as unknown as MemoryHookService); - const input = createTestInput({ toolName: 'Read' }); + const input = createTestInput({ + toolName: 'Read', + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test' }, + }); const result = await hook.execute(input); expect(result.continue).toBe(true); @@ -400,4 +487,64 @@ describe('Hook Handlers', () => { expect(result.error).toBeDefined(); }); }); + + describe('UserMessageHook', () => { + it('should display status for new project (no context)', async () => { + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const hook = trackHook(createUserMessageHook(TEST_DIR)); + const input = createTestInput(); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + // Should write to stderr + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); + expect(output).toContain('AgentKits Memory Loaded'); + expect(output).toContain('Fresh memory'); + expect(output).toContain('memory_save'); + stderrSpy.mockRestore(); + }); + + it('should display stats when context exists', async () => { + // Set up session with observations and prompts + const service = new MemoryHookService(TEST_DIR); + await service.initSession('old-session', 'test-project', 'Test task'); + await service.saveUserPrompt('old-session', 'test-project', 'Test task'); + await service.storeObservation('old-session', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + await service.completeSession('old-session', 'Done'); + await service.shutdown(); + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const hook = trackHook(createUserMessageHook(TEST_DIR)); + const input = createTestInput({ sessionId: 'new-session' }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join('\n'); + expect(output).toContain('AgentKits Memory Loaded'); + expect(output).toContain('observation'); + expect(output).toContain('memory_search'); + stderrSpy.mockRestore(); + }); + + it('should handle errors gracefully', async () => { + const hook = new UserMessageHook({ + initialize: async () => { throw new Error('Test error'); }, + } as unknown as MemoryHookService); + + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const input = createTestInput(); + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + expect(result.error).toBeDefined(); + stderrSpy.mockRestore(); + }); + }); }); diff --git a/src/hooks/__tests__/integration.test.ts b/src/hooks/__tests__/integration.test.ts index 55c5ce0..2e5b95d 100644 --- a/src/hooks/__tests__/integration.test.ts +++ b/src/hooks/__tests__/integration.test.ts @@ -87,7 +87,10 @@ describe('Hook System Integration', () => { ); expect(contextResult.continue).toBe(true); - expect(contextResult.additionalContext).toBeUndefined(); // No previous sessions + // Empty state now injects guidance (save-first workflow) + expect(contextResult.additionalContext).toBeDefined(); + expect(contextResult.additionalContext).toContain('memory_save'); + expect(contextResult.additionalContext).toContain('Do NOT call'); await contextHook.shutdown(); // 2. User Prompt Submit - Session Init Hook @@ -333,8 +336,8 @@ describe('Hook System Integration', () => { sessionId, project, toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file.ts' }, + toolResponse: { content: 'test content' }, })); // Another successful observation @@ -342,8 +345,8 @@ describe('Hook System Integration', () => { sessionId, project, toolName: 'Write', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/output.ts' }, + toolResponse: { success: true }, })); await obsHook.shutdown(); @@ -376,8 +379,8 @@ describe('Hook System Integration', () => { sessionId: 'multi-1', project, toolName: 'Read', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file1.ts' }, + toolResponse: { content: 'content1' }, })); await obsHook1.shutdown(); @@ -386,8 +389,8 @@ describe('Hook System Integration', () => { sessionId: 'multi-2', project, toolName: 'Write', - toolInput: {}, - toolResponse: {}, + toolInput: { file_path: '/test/file2.ts' }, + toolResponse: { success: true }, })); await obsHook2.shutdown(); diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index c56b569..35796ce 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync, rmSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; +import { _setQueryFunctionForTesting, resetAIEnrichmentCache, type QueryFunction } from '../ai-enrichment.js'; const TEST_DIR = path.join(process.cwd(), '.test-memory-hooks'); @@ -247,6 +248,46 @@ describe('MemoryHookService', () => { expect(context.markdown).toContain('test-project'); }); + it('should include tool-usage instructions in context header', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain('Memory tools available'); + expect(context.markdown).toContain('memory_search'); + expect(context.markdown).toContain('memory_timeline'); + expect(context.markdown).toContain('memory_details'); + expect(context.markdown).toContain('memory_save'); + expect(context.markdown).toContain('memory_recall'); + expect(context.markdown).toContain('memory_delete'); + expect(context.markdown).toContain('memory_update'); + }); + + it('should include observation IDs in context', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'file.ts' }, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + const obs = context.recentObservations[0]; + + // Observation ID should appear in markdown (format: [obs_xxxx_yyyy]) + expect(context.markdown).toContain(`[${obs.id}]`); + }); + + it('should include token economics footer when context exists', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + await service.completeSession('session-1', 'Done'); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain('tokens shown'); + expect(context.markdown).toContain('tokens available'); + expect(context.markdown).toContain('memory_search'); + expect(context.markdown).toContain('memory_details'); + }); + it('should include all observation type icons in context', async () => { await service.initSession('session-1', 'test-project'); @@ -424,6 +465,117 @@ describe('MemoryHookService', () => { }); }); + describe('enrichObservation', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setQueryFunctionForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should enrich an existing observation with AI data', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'auth.ts' }, { content: 'export class Auth {}' }, TEST_DIR + ); + + // Reset cache and set up mock AI enrichment + resetAIEnrichmentCache(); + const validResponse = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module to understand login flow.', + facts: ['File has 200 lines', 'Uses JWT tokens'], + concepts: ['authentication', 'jwt'], + }); + const mockFn: QueryFunction = () => { + return (async function* () { + yield { type: 'result', subtype: 'success', result: validResponse }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(true); + + // Verify the observation was updated in DB + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].subtitle).toBe('Examining auth module'); + expect(observations[0].narrative).toBe('Read the auth module to understand login flow.'); + expect(observations[0].facts).toContain('File has 200 lines'); + expect(observations[0].concepts).toContain('jwt'); + }); + + it('should return false for non-existent observation', async () => { + await service.initialize(); + const result = await service.enrichObservation('obs_nonexistent_0000'); + expect(result).toBe(false); + }); + + it('should return false when AI enrichment returns null', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + // Mock AI that returns invalid response + const mockFn: QueryFunction = () => { + return (async function* () { + yield { type: 'result', subtype: 'success', result: 'not valid json' }; + })(); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(false); + + // Original template data should still be intact + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].subtitle.length).toBeGreaterThan(0); + }); + + it('should return false when AI enrichment throws', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + // Mock AI that throws + const mockFn: QueryFunction = () => { + throw new Error('SDK error'); + }; + _setQueryFunctionForTesting(mockFn); + + const result = await service.enrichObservation(obs.id); + expect(result).toBe(false); + }); + + it('should preserve template data when enrichment is disabled', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'src/index.ts' }, { content: 'hello' }, TEST_DIR + ); + + // Template data should be present immediately + expect(obs.subtitle).toBeDefined(); + expect(obs.subtitle.length).toBeGreaterThan(0); + expect(obs.narrative).toBeDefined(); + expect(obs.narrative.length).toBeGreaterThan(0); + }); + }); + describe('persistence', () => { it('should auto-recreate database if deleted', async () => { // Create and populate first instance diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index 16d050f..a153a1c 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -53,6 +53,19 @@ function isEnvEnabled(): boolean | null { return value === 'true' || value === '1'; } +/** + * Synchronous check: is AI enrichment potentially enabled? + * Used by observation hook to decide whether to spawn background process. + * Does NOT check SDK availability (that's async). Just checks env var. + */ +export function isAIEnrichmentEnabled(): boolean { + const envEnabled = isEnvEnabled(); + if (envEnabled === false) return false; + // If explicitly enabled or auto-detect, optimistically return true. + // The background process will handle SDK availability check. + return true; +} + /** * Check if Claude Agent SDK is available and cache the result */ diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 9b50230..4cb18b6 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -13,6 +13,8 @@ * session-init - UserPromptSubmit: initialize session * observation - PostToolUse: capture tool usage * summarize - Stop: generate session summary + * user-message - SessionStart: display status to user (stderr) + * enrich [cwd] - Background: AI-enrich a stored observation * * @module @agentkits/memory/hooks/cli */ @@ -22,6 +24,8 @@ import { createContextHook } from './context.js'; import { createSessionInitHook } from './session-init.js'; import { createObservationHook } from './observation.js'; import { createSummarizeHook } from './summarize.js'; +import { createUserMessageHook } from './user-message.js'; +import { MemoryHookService } from './service.js'; /** * Read stdin until EOF @@ -65,10 +69,23 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich'); process.exit(1); } + // Handle 'enrich' command directly (no stdin, runs as background process) + if (event === 'enrich') { + const obsId = process.argv[3]; + const cwdArg = process.argv[4] || process.cwd(); + if (obsId) { + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + await svc.enrichObservation(obsId); + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); @@ -95,6 +112,10 @@ async function main(): Promise { result = await createSummarizeHook(input.cwd).execute(input); break; + case 'user-message': + result = await createUserMessageHook(input.cwd).execute(input); + break; + default: console.error(`Unknown event: ${event}`); console.log(JSON.stringify(STANDARD_RESPONSE)); diff --git a/src/hooks/context.ts b/src/hooks/context.ts index f1e8aca..699ca12 100644 --- a/src/hooks/context.ts +++ b/src/hooks/context.ts @@ -49,20 +49,22 @@ export class ContextHook implements EventHandler { // Get context for this project const context = await this.service.getContext(input.project); + const hasHistory = context.markdown && !context.markdown.includes('No previous session context'); - // No context to inject - if (!context.markdown || context.markdown.includes('No previous session context')) { + if (hasHistory) { + // Inject full context with history return { continue: true, - suppressOutput: true, + suppressOutput: false, + additionalContext: context.markdown, }; } - // Inject context as additional context + // Empty state: still inject tool guidance so Claude knows memory tools exist return { continue: true, suppressOutput: false, - additionalContext: context.markdown, + additionalContext: this.buildEmptyStateGuidance(input.project), }; } catch (error) { // Log error but don't block session @@ -75,6 +77,36 @@ export class ContextHook implements EventHandler { }; } } + + /** + * Build guidance for empty state (no previous sessions/memories). + * Teaches Claude about available memory tools and proper usage order. + */ + private buildEmptyStateGuidance(project: string): string { + return `# Memory Context - ${project} + +> **Memory tools available** — Use MCP tools to search and manage project memory: +> \`memory_save\`, \`memory_recall\`, \`memory_list\`, \`memory_search\`, \`memory_timeline\`, \`memory_details\`, \`memory_update\`, \`memory_delete\`, \`memory_status\` + +## Getting Started + +No previous session context found. This is a fresh memory. + +**To build memory**, use \`memory_save(content, category, tags, importance)\` to store: +- **decisions** — architectural choices, tech stack picks, trade-offs +- **patterns** — coding conventions, project patterns, recurring approaches +- **errors** — bug fixes, error solutions, debugging insights +- **context** — project background, team conventions, environment setup + +**Important:** Do NOT call \`memory_search\`, \`memory_timeline\`, or \`memory_details\` until memories exist. +Use \`memory_status()\` to check if memories are available before searching. + +**After saving**, use the 3-layer search workflow: +1. \`memory_search(query)\` → Get index with IDs (~50 tokens/result) +2. \`memory_timeline(anchor="ID")\` → Get context around interesting results +3. \`memory_details(ids=["ID1","ID2"])\` → Fetch full content ONLY for filtered IDs +`; + } } /** diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 60c3c97..facaada 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -18,6 +18,7 @@ export { ContextHook, createContextHook } from './context.js'; export { SessionInitHook, createSessionInitHook } from './session-init.js'; export { ObservationHook, createObservationHook } from './observation.js'; export { SummarizeHook, createSummarizeHook } from './summarize.js'; +export { UserMessageHook, createUserMessageHook } from './user-message.js'; // Re-export default service export { default } from './service.js'; diff --git a/src/hooks/observation.ts b/src/hooks/observation.ts index 785f93b..9100ac0 100644 --- a/src/hooks/observation.ts +++ b/src/hooks/observation.ts @@ -7,21 +7,37 @@ * @module @agentkits/memory/hooks/observation */ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; import { NormalizedHookInput, HookResult, EventHandler, } from './types.js'; import { MemoryHookService } from './service.js'; +import { isAIEnrichmentEnabled } from './ai-enrichment.js'; /** - * Tools to skip capturing (internal/noisy tools) + * Tools to skip capturing (internal/noisy tools). + * Includes our own memory MCP tools to avoid self-referential loops. */ const SKIP_TOOLS = new Set([ 'TodoWrite', 'TodoRead', 'AskFollowupQuestion', + 'AskUserQuestion', 'AttemptCompletion', + // Skip our own memory tools (avoid capturing memory ops as observations) + 'mcp__memory__memory_save', + 'mcp__memory__memory_search', + 'mcp__memory__memory_timeline', + 'mcp__memory__memory_details', + 'mcp__memory__memory_delete', + 'mcp__memory__memory_update', + 'mcp__memory__memory_recall', + 'mcp__memory__memory_list', + 'mcp__memory__memory_status', + 'mcp__memory____IMPORTANT', ]); /** @@ -69,14 +85,24 @@ export class ObservationHook implements EventHandler { }; } + // Skip empty/no-op tool calls (e.g. Read with no file_path) + const inputStr = JSON.stringify(input.toolInput || {}); + const responseStr = JSON.stringify(input.toolResponse || {}); + if (inputStr === '{}' && responseStr === '{}') { + return { + continue: true, + suppressOutput: true, + }; + } + // Initialize service await this.service.initialize(); // Ensure session exists (create if not) await this.service.initSession(input.sessionId, input.project); - // Store the observation - await this.service.storeObservation( + // Store the observation (template-based, fast <50ms) + const obs = await this.service.storeObservation( input.sessionId, input.project, input.toolName, @@ -85,6 +111,21 @@ export class ObservationHook implements EventHandler { input.cwd ); + // Fire-and-forget: spawn detached process for AI enrichment + if (isAIEnrichmentEnabled()) { + try { + const cliPath = path.resolve(input.cwd, 'dist/hooks/cli.js'); + const child = spawn('node', [cliPath, 'enrich', obs.id, input.cwd], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + child.unref(); + } catch { + // Silently ignore — template data already saved + } + } + return { continue: true, suppressOutput: true, diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 8d30170..74e2f16 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -294,19 +294,19 @@ export class MemoryHookService { const promptNumber = this.getPromptNumber(sessionId); const { filesRead, filesModified } = extractFilePaths(toolName, toolInput); - // Truncate large responses (needed for both AI and template extraction) + // Truncate large responses const inputStr = JSON.stringify(toolInput || {}); const responseStr = truncate( JSON.stringify(toolResponse || {}), this.config.maxResponseSize ); - // Try AI enrichment first, fall back to template-based extraction - const aiResult = await enrichWithAI(toolName, inputStr, responseStr).catch(() => null); - const subtitle = aiResult?.subtitle || generateObservationSubtitle(toolName, toolInput, toolResponse); - const narrative = aiResult?.narrative || generateObservationNarrative(toolName, toolInput, toolResponse); - const facts = aiResult?.facts || extractFacts(toolName, toolInput, toolResponse); - const concepts = aiResult?.concepts || extractConcepts(toolName, toolInput, toolResponse); + // Template-based extraction only (fast, <10ms) + // AI enrichment runs asynchronously via fire-and-forget process + const subtitle = generateObservationSubtitle(toolName, toolInput, toolResponse); + const narrative = generateObservationNarrative(toolName, toolInput, toolResponse); + const facts = extractFacts(toolName, toolInput, toolResponse); + const concepts = extractConcepts(toolName, toolInput, toolResponse); this.db!.prepare(` INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts) @@ -341,6 +341,38 @@ export class MemoryHookService { }; } + /** + * Enrich an existing observation with AI-generated data. + * Called from a background process after the observation is saved. + * Updates subtitle, narrative, facts, and concepts in-place. + */ + async enrichObservation(id: string): Promise { + await this.ensureInitialized(); + + const row = this.db!.prepare( + 'SELECT tool_name, tool_input, tool_response FROM observations WHERE id = ?' + ).get(id) as { tool_name: string; tool_input: string; tool_response: string } | undefined; + + if (!row) return false; + + const aiResult = await enrichWithAI(row.tool_name, row.tool_input, row.tool_response).catch(() => null); + if (!aiResult) return false; + + this.db!.prepare(` + UPDATE observations + SET subtitle = ?, narrative = ?, facts = ?, concepts = ? + WHERE id = ? + `).run( + aiResult.subtitle, + aiResult.narrative, + JSON.stringify(aiResult.facts), + JSON.stringify(aiResult.concepts), + id + ); + + return true; + } + /** * Get observations for a session */ @@ -422,7 +454,11 @@ export class MemoryHookService { lines.push(`# Memory Context - ${project}`); lines.push(''); - lines.push('*AgentKits CPS - Auto-captured session memory*'); + + // Tool-usage instruction header (CRITICAL for LLM tool adoption) + lines.push('> **Memory tools available** — Use MCP tools to search and manage project memory:'); + lines.push('> `memory_search(query)` → `memory_timeline(anchor)` → `memory_details(ids)` (3-layer workflow)'); + lines.push('> Also: `memory_save`, `memory_recall`, `memory_list`, `memory_delete`, `memory_update`, `memory_status`'); lines.push(''); // Structured summaries from previous sessions (most valuable context) @@ -461,7 +497,7 @@ export class MemoryHookService { lines.push(''); } - // Recent observations with enriched details + // Recent observations with enriched details and IDs if (observations.length > 0) { lines.push('## Recent Activity'); lines.push(''); @@ -470,7 +506,7 @@ export class MemoryHookService { const time = this.formatRelativeTime(obs.timestamp); const icon = this.getObservationIcon(obs.type); const detail = obs.subtitle || obs.title || obs.toolName; - lines.push(`- ${icon} **${detail}** (${time})`); + lines.push(`- ${icon} **${detail}** (${time}) [${obs.id}]`); if (obs.narrative) { lines.push(` ${obs.narrative}`); } @@ -510,6 +546,17 @@ export class MemoryHookService { lines.push(''); } + // Token economics footer (motivates LLM to use progressive disclosure) + const totalObs = observations.length; + const totalSessions = summaries.length || sessions.length; + if (totalObs > 0 || totalSessions > 0) { + const estimatedFullTokens = (totalObs * 500) + (totalSessions * 200); + const contextTokens = lines.join('\n').length / 4; // rough estimate + lines.push('---'); + lines.push(`*Context: ~${Math.round(contextTokens)} tokens shown. ~${estimatedFullTokens.toLocaleString()} tokens available via \`memory_search\` → \`memory_details\`.*`); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/hooks/user-message.ts b/src/hooks/user-message.ts new file mode 100644 index 0000000..abd213a --- /dev/null +++ b/src/hooks/user-message.ts @@ -0,0 +1,103 @@ +/** + * User Message Hook Handler (SessionStart - parallel) + * + * Displays memory status info to user via stderr. + * Runs alongside context hook but only writes to stderr + * (visible to user in Claude Code UI) without injecting + * into Claude's conversation context. + * + * @module @agentkits/memory/hooks/user-message + */ + +import { + NormalizedHookInput, + HookResult, + EventHandler, +} from './types.js'; +import { MemoryHookService } from './service.js'; + +/** + * User Message Hook - SessionStart Event + * + * Shows memory system status to user in terminal. + * Does NOT inject anything into Claude's context. + */ +export class UserMessageHook implements EventHandler { + private service: MemoryHookService; + private ownsService: boolean; + + constructor(service: MemoryHookService, ownsService = false) { + this.service = service; + this.ownsService = ownsService; + } + + /** + * Shutdown the hook (closes database if owned) + */ + async shutdown(): Promise { + if (this.ownsService) { + await this.service.shutdown(); + } + } + + /** + * Execute the user message hook + */ + async execute(input: NormalizedHookInput): Promise { + try { + // Initialize service + await this.service.initialize(); + + // Get context to count observations + const context = await this.service.getContext(input.project); + const obsCount = context.recentObservations.length; + const sessionCount = context.sessionSummaries.length || context.previousSessions.length; + const promptCount = context.userPrompts.length; + + // Build status display + const parts: string[] = []; + parts.push(''); + parts.push(' AgentKits Memory Loaded'); + + if (obsCount > 0 || sessionCount > 0 || promptCount > 0) { + const stats: string[] = []; + if (sessionCount > 0) stats.push(`${sessionCount} session${sessionCount > 1 ? 's' : ''}`); + if (obsCount > 0) stats.push(`${obsCount} observation${obsCount > 1 ? 's' : ''}`); + if (promptCount > 0) stats.push(`${promptCount} prompt${promptCount > 1 ? 's' : ''}`); + parts.push(` Context: ${stats.join(', ')}`); + parts.push(' Use: memory_search → memory_timeline → memory_details'); + } else { + parts.push(' Fresh memory — use memory_save to start building context'); + } + + parts.push(''); + + // Write to stderr for user visibility + console.error(parts.join('\n')); + + return { + continue: true, + suppressOutput: true, + }; + } catch (error) { + // Log error but don't block session + console.error('[AgentKits Memory] User message hook error:', error); + + return { + continue: true, + suppressOutput: true, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } +} + +/** + * Create user message hook handler + */ +export function createUserMessageHook(cwd: string): UserMessageHook { + const service = new MemoryHookService(cwd); + return new UserMessageHook(service, true); +} + +export default UserMessageHook; diff --git a/src/mcp/__tests__/server.test.ts b/src/mcp/__tests__/server.test.ts index fee3884..cff7774 100644 --- a/src/mcp/__tests__/server.test.ts +++ b/src/mcp/__tests__/server.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ProjectMemoryService, DEFAULT_NAMESPACES } from '../../index.js'; -import { MEMORY_TOOLS } from '../tools.js'; +import { MEMORY_TOOLS, SEARCH_STRATEGY_TIPS } from '../tools.js'; import type { MemorySaveArgs, MemorySearchArgs, @@ -16,6 +16,8 @@ import type { MemoryListArgs, MemoryTimelineArgs, MemoryDetailsArgs, + MemoryDeleteArgs, + MemoryUpdateArgs, } from '../types.js'; // Mock ProjectMemoryService for isolated testing @@ -32,15 +34,22 @@ describe('MCP Server', () => { it('should export all required tools', () => { const toolNames = MEMORY_TOOLS.map(t => t.name); + expect(toolNames).toContain('__IMPORTANT'); expect(toolNames).toContain('memory_save'); expect(toolNames).toContain('memory_search'); expect(toolNames).toContain('memory_timeline'); expect(toolNames).toContain('memory_details'); + expect(toolNames).toContain('memory_delete'); + expect(toolNames).toContain('memory_update'); expect(toolNames).toContain('memory_recall'); expect(toolNames).toContain('memory_list'); expect(toolNames).toContain('memory_status'); }); + it('should export exactly 10 tools', () => { + expect(MEMORY_TOOLS).toHaveLength(10); + }); + it('should have valid input schemas for all tools', () => { for (const tool of MEMORY_TOOLS) { expect(tool.inputSchema).toBeDefined(); @@ -152,6 +161,84 @@ describe('MCP Server', () => { }); }); + describe('__IMPORTANT meta-tool', () => { + const importantTool = MEMORY_TOOLS.find(t => t.name === '__IMPORTANT')!; + + it('should exist as the first tool', () => { + expect(MEMORY_TOOLS[0].name).toBe('__IMPORTANT'); + }); + + it('should describe 3-layer workflow', () => { + expect(importantTool.description).toContain('MEMORY WORKFLOW'); + expect(importantTool.description).toContain('memory_search'); + expect(importantTool.description).toContain('memory_timeline'); + expect(importantTool.description).toContain('memory_details'); + expect(importantTool.description).toContain('Do NOT call memory_search'); + }); + + it('should mention all available tools', () => { + expect(importantTool.description).toContain('memory_save'); + expect(importantTool.description).toContain('memory_delete'); + expect(importantTool.description).toContain('memory_update'); + expect(importantTool.description).toContain('memory_recall'); + expect(importantTool.description).toContain('memory_list'); + expect(importantTool.description).toContain('memory_status'); + }); + + it('should have empty properties (not callable)', () => { + expect(Object.keys(importantTool.inputSchema.properties)).toHaveLength(0); + }); + }); + + describe('memory_delete tool', () => { + const deleteTool = MEMORY_TOOLS.find(t => t.name === 'memory_delete')!; + + it('should require ids parameter', () => { + expect(deleteTool.inputSchema.required).toContain('ids'); + }); + + it('should have ids as array type', () => { + const idsProp = deleteTool.inputSchema.properties.ids; + expect(idsProp.type).toBe('array'); + expect(idsProp.items).toEqual({ type: 'string' }); + }); + }); + + describe('memory_update tool', () => { + const updateTool = MEMORY_TOOLS.find(t => t.name === 'memory_update')!; + + it('should require id parameter', () => { + expect(updateTool.inputSchema.required).toContain('id'); + }); + + it('should have optional content and tags', () => { + expect(updateTool.inputSchema.properties.content).toBeDefined(); + expect(updateTool.inputSchema.properties.tags).toBeDefined(); + const required = updateTool.inputSchema.required || []; + expect(required).not.toContain('content'); + expect(required).not.toContain('tags'); + }); + }); + + describe('memory_search advanced params', () => { + const searchTool = MEMORY_TOOLS.find(t => t.name === 'memory_search')!; + + it('should have dateStart filter option', () => { + expect(searchTool.inputSchema.properties.dateStart).toBeDefined(); + expect(searchTool.inputSchema.properties.dateStart.type).toBe('string'); + }); + + it('should have dateEnd filter option', () => { + expect(searchTool.inputSchema.properties.dateEnd).toBeDefined(); + expect(searchTool.inputSchema.properties.dateEnd.type).toBe('string'); + }); + + it('should have orderBy option with valid enum', () => { + expect(searchTool.inputSchema.properties.orderBy).toBeDefined(); + expect(searchTool.inputSchema.properties.orderBy.enum).toEqual(['relevance', 'date_asc', 'date_desc']); + }); + }); + describe('memory_status tool', () => { const statusTool = MEMORY_TOOLS.find(t => t.name === 'memory_status')!; @@ -165,6 +252,24 @@ describe('MCP Server', () => { }); }); + describe('SEARCH_STRATEGY_TIPS', () => { + it('should contain 3-layer workflow steps', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('memory_search'); + expect(SEARCH_STRATEGY_TIPS).toContain('memory_timeline'); + expect(SEARCH_STRATEGY_TIPS).toContain('memory_details'); + }); + + it('should mention token savings', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('87%'); + }); + + it('should mention filtering tips', () => { + expect(SEARCH_STRATEGY_TIPS).toContain('category'); + expect(SEARCH_STRATEGY_TIPS).toContain('dateStart'); + expect(SEARCH_STRATEGY_TIPS).toContain('orderBy'); + }); + }); + describe('Tool Argument Types', () => { it('MemorySaveArgs should accept valid arguments', () => { const args: MemorySaveArgs = { @@ -255,5 +360,52 @@ describe('MCP Server', () => { expect(args.ids).toContain('memory-2'); expect(args.ids).toContain('memory-3'); }); + + it('MemoryDeleteArgs should accept valid arguments', () => { + const args: MemoryDeleteArgs = { + ids: ['memory-1', 'memory-2'], + }; + + expect(args.ids).toHaveLength(2); + expect(args.ids).toContain('memory-1'); + }); + + it('MemoryUpdateArgs should accept valid arguments', () => { + const args: MemoryUpdateArgs = { + id: 'memory-1', + content: 'Updated content', + tags: 'tag1,tag2', + }; + + expect(args.id).toBe('memory-1'); + expect(args.content).toBe('Updated content'); + expect(args.tags).toBe('tag1,tag2'); + }); + + it('MemoryUpdateArgs should work with minimal arguments', () => { + const args: MemoryUpdateArgs = { + id: 'memory-1', + }; + + expect(args.id).toBe('memory-1'); + expect(args.content).toBeUndefined(); + expect(args.tags).toBeUndefined(); + }); + + it('MemorySearchArgs should accept advanced filter arguments', () => { + const args: MemorySearchArgs = { + query: 'search term', + limit: 20, + category: 'decision', + dateStart: '2025-01-01', + dateEnd: '2025-12-31', + orderBy: 'date_desc', + }; + + expect(args.query).toBe('search term'); + expect(args.dateStart).toBe('2025-01-01'); + expect(args.dateEnd).toBe('2025-12-31'); + expect(args.orderBy).toBe('date_desc'); + }); }); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dca8f7b..b0ad815 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,6 +4,7 @@ * * Model Context Protocol server for Claude Code memory access. * Provides tools for saving, searching, and recalling memories. + * Implements 3-layer progressive disclosure for token efficiency. * * Usage: * Add to .mcp.json: @@ -21,8 +22,23 @@ import * as readline from 'node:readline'; import * as path from 'node:path'; -import { ProjectMemoryService, MemoryEntry, MemoryQuery, DEFAULT_NAMESPACES, LocalEmbeddingsService } from '../index.js'; -import { MEMORY_TOOLS } from './tools.js'; + +// CRITICAL: Redirect console.log to stderr BEFORE other imports. +// MCP uses stdio transport — stdout is reserved for JSON-RPC protocol messages. +// Any stray console.log (from libraries, debug code) breaks the protocol. +const _originalConsoleLog = console.log; +console.log = (...args: unknown[]) => { + // Only allow JSON-RPC messages (start with '{') + if (args.length === 1 && typeof args[0] === 'string' && args[0].startsWith('{')) { + _originalConsoleLog.apply(console, args); + } else { + console.error('[MCP stdout intercepted]', ...args); + } +}; + +import { ProjectMemoryService, MemoryEntry, MemoryQuery, DEFAULT_NAMESPACES } from '../index.js'; +import { EmbeddingSubprocess } from '../embeddings/embedding-subprocess.js'; +import { MEMORY_TOOLS, SEARCH_STRATEGY_TIPS } from './tools.js'; import type { JSONRPCRequest, JSONRPCResponse, @@ -34,6 +50,8 @@ import type { MemoryListArgs, MemoryTimelineArgs, MemoryDetailsArgs, + MemoryDeleteArgs, + MemoryUpdateArgs, } from './types.js'; // Map category names to namespaces @@ -50,7 +68,7 @@ const CATEGORY_TO_NAMESPACE: Record = { */ class MemoryMCPServer { private service: ProjectMemoryService | null = null; - private embeddingsService: LocalEmbeddingsService | null = null; + private embeddingSubprocess: EmbeddingSubprocess | null = null; private projectDir: string; private initialized = false; @@ -59,23 +77,22 @@ class MemoryMCPServer { } /** - * Initialize the memory service with embeddings support + * Initialize the memory service with subprocess embeddings. + * The embedding model loads in a background child process — + * requests are queued until the worker is ready, with mock fallback on timeout. */ private async ensureInitialized(): Promise { if (!this.service || !this.initialized) { const baseDir = path.join(this.projectDir, '.claude/memory'); - // Initialize embeddings service - this.embeddingsService = new LocalEmbeddingsService({ + // Spawn embedding worker process (returns immediately, loads model in background) + this.embeddingSubprocess = new EmbeddingSubprocess({ cacheDir: path.join(baseDir, 'embeddings-cache'), }); - await this.embeddingsService.initialize(); + this.embeddingSubprocess.spawn(); - // Create embedding generator function - const embeddingGenerator = async (text: string): Promise => { - const result = await this.embeddingsService!.embed(text); - return result.embedding; - }; + // Get embedding generator (queues requests until worker is ready) + const embeddingGenerator = this.embeddingSubprocess.getGenerator(); this.service = new ProjectMemoryService({ baseDir, @@ -143,7 +160,7 @@ class MemoryMCPServer { }, serverInfo: { name: 'agentkits-memory', - version: '1.0.0', + version: '2.1.0', }, }, }; @@ -184,6 +201,11 @@ class MemoryMCPServer { args: Record ): Promise { try { + // __IMPORTANT is a meta-tool, no service needed + if (name === '__IMPORTANT') { + return this.toolImportant(); + } + const service = await this.ensureInitialized(); switch (name) { @@ -199,6 +221,12 @@ class MemoryMCPServer { case 'memory_details': return this.toolDetails(service, args as unknown as MemoryDetailsArgs); + case 'memory_delete': + return this.toolDelete(service, args as unknown as MemoryDeleteArgs); + + case 'memory_update': + return this.toolUpdate(service, args as unknown as MemoryUpdateArgs); + case 'memory_recall': return this.toolRecall(service, args as unknown as MemoryRecallArgs); @@ -225,6 +253,42 @@ class MemoryMCPServer { } } + /** + * __IMPORTANT meta-tool: returns workflow instructions + */ + private toolImportant(): ToolCallResult { + return { + content: [{ + type: 'text', + text: `# Memory Tool Workflow + +## Step 0: Check before searching +Use \`memory_status()\` to check if memories exist. +**Do NOT call memory_search, memory_timeline, or memory_details on empty memory.** +If no memories exist, use \`memory_save\` first to build the knowledge base. + +## Saving memories +\`memory_save(content, category, tags, importance)\` — Store decisions, patterns, errors, context. +Categories: decision, pattern, error, context, observation. + +## 3-Layer Progressive Disclosure (for searching AFTER memories exist): + +1. **Search** — \`memory_search(query)\` → index with IDs (~50 tokens/result) +2. **Timeline** — \`memory_timeline(anchor="ID")\` → temporal context +3. **Details** — \`memory_details(ids=["ID1","ID2"])\` → full content + +**Why:** 10x token savings. Never fetch details without filtering first. + +## Other tools +- \`memory_recall(topic)\` — Quick topic summary +- \`memory_list(category, limit)\` — List recent memories +- \`memory_update(id, content, tags)\` — Update existing +- \`memory_delete(ids)\` — Remove by ID +- \`memory_status()\` — Health check`, + }], + }; + } + /** * Save memory tool */ @@ -256,7 +320,8 @@ class MemoryMCPServer { return { content: [{ type: 'text', - text: `Saved to memory (${category}): "${args.content.slice(0, 100)}${args.content.length > 100 ? '...' : ''}"`, + text: `Saved to memory (${category}): "${args.content.slice(0, 100)}${args.content.length > 100 ? '...' : ''}" +ID: ${entry.id}`, }], }; } @@ -264,6 +329,7 @@ class MemoryMCPServer { /** * Search memory tool (Progressive Disclosure Layer 1) * Returns lightweight index: id, title, category, score + * Supports advanced filters: dateStart, dateEnd, orderBy */ private async toolSearch( service: ProjectMemoryService, @@ -282,23 +348,41 @@ class MemoryMCPServer { content: args.query, }; - const results = await service.query(query); + let results = await service.query(query); + + // Apply date filters + if (args.dateStart) { + const startTime = new Date(args.dateStart).getTime(); + results = results.filter((e: MemoryEntry) => new Date(e.createdAt).getTime() >= startTime); + } + if (args.dateEnd) { + const endTime = new Date(args.dateEnd).getTime(); + results = results.filter((e: MemoryEntry) => new Date(e.createdAt).getTime() <= endTime); + } + + // Apply ordering + if (args.orderBy === 'date_asc') { + results.sort((a: MemoryEntry, b: MemoryEntry) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + } else if (args.orderBy === 'date_desc') { + results.sort((a: MemoryEntry, b: MemoryEntry) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + // 'relevance' = default hybrid search ordering if (results.length === 0) { return { content: [{ type: 'text', - text: `No memories found for: "${args.query}"`, + text: `No memories found for: "${args.query}"${SEARCH_STRATEGY_TIPS}`, }], }; } // Progressive Disclosure Layer 1: Return lightweight index only - // Full content requires memory_details(ids) - const index = results.map((entry: MemoryEntry, i: number) => { + const index = results.map((entry: MemoryEntry) => { const category = entry.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || entry.namespace; const date = new Date(entry.createdAt).toLocaleDateString(); - // Extract title from content (first line or first 60 chars) const title = entry.content.split('\n')[0].slice(0, 60) + (entry.content.length > 60 ? '...' : ''); const score = (entry as MemoryEntry & { score?: number }).score; @@ -306,7 +390,7 @@ class MemoryMCPServer { id: entry.id, title, category, - tags: entry.tags.slice(0, 3), // Limit tags + tags: entry.tags.slice(0, 3), date, score: score ? Math.round(score * 100) : undefined, }; @@ -323,11 +407,7 @@ class MemoryMCPServer { text: `## Search Results (${results.length} memories) ${formatted} - ---- -**Next steps:** -- \`memory_timeline(anchor: "ID")\` - Get context around a memory -- \`memory_details(ids: ["ID1", "ID2"])\` - Get full content`, +${SEARCH_STRATEGY_TIPS}`, }], }; } @@ -379,6 +459,9 @@ ${formatted} new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); + // Collect IDs for convenience + const nearbyIds = nearby.map((e: MemoryEntry) => e.id); + // Format timeline const category = anchor.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || anchor.namespace; const anchorTitle = anchor.content.split('\n')[0].slice(0, 60); @@ -398,13 +481,15 @@ ${formatted} **Anchor:** ${anchorTitle} **Category:** ${category} **Time range:** ${before}min before → ${after}min after +**Entries:** ${nearby.length} \`\`\` ${timeline} \`\`\` --- -**Next:** \`memory_details(ids: ["${anchor.id}"])\` - Get full content`, +**Next:** \`memory_details(ids: ${JSON.stringify(nearbyIds.slice(0, 5))})\` — Get full content for these entries +${SEARCH_STRATEGY_TIPS}`, }], }; } @@ -429,9 +514,6 @@ ${timeline} // Limit to prevent token explosion const ids = args.ids.slice(0, 5); - if (args.ids.length > 5) { - // Will add note about limit - } const memories: MemoryEntry[] = []; for (const id of ids) { @@ -475,6 +557,97 @@ ${entry.content}`; }; } + /** + * Delete memories by ID + */ + private async toolDelete( + service: ProjectMemoryService, + args: MemoryDeleteArgs + ): Promise { + if (!args.ids || args.ids.length === 0) { + return { + content: [{ + type: 'text', + text: 'No memory IDs provided. Use memory_search first to find IDs.', + }], + isError: true, + }; + } + + const deleted: string[] = []; + const notFound: string[] = []; + + for (const id of args.ids) { + const entry = await service.get(id); + if (entry) { + await service.delete(id); + deleted.push(id); + } else { + notFound.push(id); + } + } + + let output = `Deleted ${deleted.length} memor${deleted.length === 1 ? 'y' : 'ies'}.`; + if (deleted.length > 0) { + output += `\nRemoved: ${deleted.join(', ')}`; + } + if (notFound.length > 0) { + output += `\nNot found: ${notFound.join(', ')}`; + } + + return { + content: [{ type: 'text', text: output }], + }; + } + + /** + * Update an existing memory + */ + private async toolUpdate( + service: ProjectMemoryService, + args: MemoryUpdateArgs + ): Promise { + if (!args.id) { + return { + content: [{ + type: 'text', + text: 'No memory ID provided. Use memory_search first to find the ID.', + }], + isError: true, + }; + } + + // Get existing entry + const existing = await service.get(args.id); + if (!existing) { + return { + content: [{ + type: 'text', + text: `Memory not found: ${args.id}`, + }], + isError: true, + }; + } + + // Build update + const updates: Partial = {}; + if (args.content) { + updates.content = args.content; + } + if (args.tags) { + updates.tags = args.tags.split(',').map((t: string) => t.trim()); + } + + await service.update(args.id, updates); + + return { + content: [{ + type: 'text', + text: `Updated memory: ${args.id}\n${args.content ? 'Content updated.' : ''}${args.tags ? ' Tags updated.' : ''}`, + }], + }; + } + /** * Recall topic tool */ @@ -495,29 +668,35 @@ ${entry.content}`; return { content: [{ type: 'text', - text: `No memories found about: "${args.topic}"`, + text: `No memories found about: "${args.topic}"\n\nTry \`memory_search(query="${args.topic}")\` for a more detailed search with filters.`, }], }; } // Group by namespace - const byNamespace: Record = {}; + const byNamespace: Record = {}; for (const entry of results) { const ns = entry.namespace || 'general'; if (!byNamespace[ns]) byNamespace[ns] = []; - byNamespace[ns].push(entry.content); + byNamespace[ns].push(entry); } - // Format output + // Format output with IDs for follow-up let output = `## Memory Recall: ${args.topic}\n\n`; - for (const [namespace, items] of Object.entries(byNamespace)) { + const allIds: string[] = []; + + for (const [namespace, entries] of Object.entries(byNamespace)) { output += `### ${namespace.charAt(0).toUpperCase() + namespace.slice(1)}\n`; - items.forEach((item: string) => { - output += `- ${item}\n`; - }); + for (const entry of entries) { + const title = entry.content.split('\n')[0].slice(0, 80); + output += `- [${entry.id}] ${title}\n`; + allIds.push(entry.id); + } output += '\n'; } + output += `---\n**For full details:** \`memory_details(ids: ${JSON.stringify(allIds.slice(0, 5))})\``; + return { content: [{ type: 'text', text: output }], }; @@ -548,7 +727,7 @@ ${entry.content}`; return { content: [{ type: 'text', - text: 'No memories stored yet.', + text: 'No memories stored yet. Use `memory_save(content, category, tags)` to store information.', }], }; } @@ -556,13 +735,13 @@ ${entry.content}`; const formatted = results.map((entry: MemoryEntry, i: number) => { const date = new Date(entry.createdAt).toLocaleString(); const category = entry.tags.find(t => Object.keys(CATEGORY_TO_NAMESPACE).includes(t)) || entry.namespace; - return `${i + 1}. [${category}] ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}\n Created: ${date}`; + return `${i + 1}. [${category}] ${entry.content.slice(0, 80)}${entry.content.length > 80 ? '...' : ''}\n ID: ${entry.id} | Created: ${date}`; }).join('\n\n'); return { content: [{ type: 'text', - text: `Recent memories (${results.length}):\n\n${formatted}`, + text: `## Recent Memories (${results.length})\n\n${formatted}\n\n---\n**For full details:** \`memory_details(ids: ["ID"])\` | **To search:** \`memory_search(query="...")\``, }], }; } @@ -582,6 +761,12 @@ ${entry.content}`; ### Namespace Breakdown ${Object.entries(stats.entriesByNamespace || {}).map(([ns, count]) => `- ${ns}: ${count}`).join('\n') || '- No entries yet'} + +### Available Tools +- \`memory_search(query)\` — Search with 3-layer progressive disclosure +- \`memory_save(content, category)\` — Store new memories +- \`memory_delete(ids)\` — Remove memories +- \`memory_update(id, content)\` — Modify existing memories `; return { @@ -623,6 +808,9 @@ ${Object.entries(stats.entriesByNamespace || {}).map(([ns, count]) => `- ${ns}: }); rl.on('close', () => { + if (this.embeddingSubprocess) { + this.embeddingSubprocess.shutdown(); + } process.exit(0); }); } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f99dc10..ab2227d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2,16 +2,47 @@ * MCP Memory Tools * * Tool definitions for the memory MCP server. + * Includes __IMPORTANT meta-tool that teaches LLMs the 3-layer workflow. * * @module @agentkits/memory/mcp/tools */ import type { MCPTool } from './types.js'; +/** + * Search strategy tips appended to search/timeline results. + * Guides LLM through progressive disclosure workflow. + */ +export const SEARCH_STRATEGY_TIPS = ` +--- +**Memory Search Strategy (3-Layer Progressive Disclosure):** +1. \`memory_search(query)\` - Get index with IDs (~50 tokens/result) +2. \`memory_timeline(anchor: "ID")\` - Get context around interesting results +3. \`memory_details(ids: ["ID1", "ID2"])\` - Fetch full content ONLY for filtered IDs + +**Tips:** Filter by category, dateStart/dateEnd, or orderBy for precise results. +NEVER fetch full details without filtering first — saves ~87% tokens.`; + /** * All available memory tools */ export const MEMORY_TOOLS: MCPTool[] = [ + // Meta-tool: teaches LLM the correct workflow (save-first, then search) + { + name: '__IMPORTANT', + description: `MEMORY WORKFLOW (ALWAYS FOLLOW): +0. memory_status() → Check if memories exist BEFORE searching +1. memory_save(content, category, tags) → Save decisions, patterns, errors, context +2. memory_search(query) → Get index with IDs (~50 tokens/result) +3. memory_timeline(anchor="ID") → Get context around interesting results +4. memory_details(ids=["ID1","ID2"]) → Fetch full content ONLY for filtered IDs +IMPORTANT: Do NOT call memory_search/timeline/details on empty memory — save first. +Also available: memory_recall, memory_list, memory_update, memory_delete.`, + inputSchema: { + type: 'object', + properties: {}, + }, + }, { name: 'memory_save', description: 'Save information to project memory. Use this to store decisions, patterns, error solutions, or important context that should persist across sessions.', @@ -61,6 +92,19 @@ This 3-step workflow saves ~87% tokens vs fetching everything.`, description: 'Filter by category', enum: ['decision', 'pattern', 'error', 'context', 'observation'], }, + dateStart: { + type: 'string', + description: 'Filter: only memories after this date (ISO 8601, e.g., "2025-01-01")', + }, + dateEnd: { + type: 'string', + description: 'Filter: only memories before this date (ISO 8601, e.g., "2025-12-31")', + }, + orderBy: { + type: 'string', + description: 'Sort order for results', + enum: ['relevance', 'date_asc', 'date_desc'], + }, }, required: ['query'], }, @@ -104,9 +148,47 @@ Only fetches memories you need, saving context tokens.`, required: ['ids'], }, }, + { + name: 'memory_delete', + description: 'Delete specific memories by ID. Use to clean up duplicates, outdated, or incorrect entries.', + inputSchema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Memory IDs to delete', + }, + }, + required: ['ids'], + }, + }, + { + name: 'memory_update', + description: 'Update an existing memory. Replaces content and/or tags of an existing entry without creating duplicates.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Memory ID to update', + }, + content: { + type: 'string', + description: 'New content (replaces existing)', + }, + tags: { + type: 'string', + description: 'New comma-separated tags (replaces existing)', + }, + }, + required: ['id'], + }, + }, { name: 'memory_recall', - description: 'Recall specific topic from memory. Gets a summary of everything known about a topic.', + description: `Recall specific topic from memory. Gets a summary of everything known about a topic. +Use for quick topic overview. For detailed investigation, use memory_search → memory_timeline → memory_details instead.`, inputSchema: { type: 'object', properties: { @@ -125,7 +207,7 @@ Only fetches memories you need, saving context tokens.`, }, { name: 'memory_list', - description: 'List recent memories. Shows what has been saved recently.', + description: 'List recent memories. Shows what has been saved recently. Use memory_search for targeted lookup.', inputSchema: { type: 'object', properties: { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f1edd6a..f481d2b 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -64,13 +64,16 @@ export interface MemorySaveArgs { } /** - * Memory search arguments + * Memory search arguments (with advanced filters) */ export interface MemorySearchArgs { query: string; limit?: number; category?: string; tags?: string[]; + dateStart?: string; // ISO 8601 + dateEnd?: string; // ISO 8601 + orderBy?: 'relevance' | 'date_asc' | 'date_desc'; } /** @@ -106,6 +109,22 @@ export interface MemoryDetailsArgs { ids: string[]; // Memory IDs from search/timeline } +/** + * Memory delete arguments + */ +export interface MemoryDeleteArgs { + ids: string[]; // Memory IDs to delete +} + +/** + * Memory update arguments + */ +export interface MemoryUpdateArgs { + id: string; // Memory ID to update + content?: string; // New content (replaces existing) + tags?: string; // New comma-separated tags (replaces existing) +} + /** * JSON-RPC request */ From 8b8370182ade54d6306ca9fb305880a5ee581e2b Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 06:04:30 +0900 Subject: [PATCH 03/21] feat: Refactor AI enrichment to use `claude --print` CLI and add session summary enrichment - Updated AI enrichment logic to utilize the `claude --print` CLI instead of the Claude Agent SDK. - Introduced new command `enrich-summary` for enriching session summaries using transcript data. - Implemented background processing for AI enrichment and embedding generation. - Enhanced MemoryHookService to manage embedding queue and worker processes. - Added utility functions for extracting the last assistant message from transcripts. - Updated tests to reflect changes in AI enrichment and CLI integration. - Expanded observation types to include `MultiEdit` for better handling of tool actions. --- src/cli/web-viewer.ts | 757 +++++++++++++++++++--- src/hooks/__tests__/ai-enrichment.test.ts | 326 +++++----- src/hooks/__tests__/service.test.ts | 27 +- src/hooks/ai-enrichment.ts | 264 +++++--- src/hooks/cli.ts | 41 +- src/hooks/service.ts | 343 +++++++++- src/hooks/summarize.ts | 23 + src/hooks/types.ts | 8 +- 8 files changed, 1431 insertions(+), 358 deletions(-) diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index e25f4ed..feb786a 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -51,6 +51,109 @@ function getDatabase(): BetterDatabase { if (_db) return _db; _db = new Database(dbPath); _db.pragma('journal_mode = WAL'); + + // Ensure all tables exist (web viewer may start before MCP server or hooks) + _db.exec(` + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT DEFAULT 'semantic', + namespace TEXT DEFAULT 'default', + tags TEXT DEFAULT '[]', + metadata TEXT DEFAULT '{}', + embedding BLOB, + session_id TEXT, + owner_id TEXT, + access_level TEXT DEFAULT 'project', + created_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000), + expires_at INTEGER, + version INTEGER DEFAULT 1, + "references" TEXT DEFAULT '[]', + access_count INTEGER DEFAULT 0, + last_accessed_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000) + ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT, + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ); + CREATE TABLE IF NOT EXISTS observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT, + title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]', + embedding BLOB, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL, + embedding BLOB, + UNIQUE(session_id, prompt_number), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + completed TEXT, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT, + notes TEXT, + prompt_number INTEGER, + created_at INTEGER NOT NULL, + embedding BLOB, + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ); + `); + + // Embedding queue table (shared with hooks service) + _db.exec(` + CREATE TABLE IF NOT EXISTS embedding_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + + // Migration: add embedding column to existing session tables + for (const table of ['observations', 'user_prompts', 'session_summaries']) { + try { + const cols = _db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'embedding')) { + _db.exec(`ALTER TABLE ${table} ADD COLUMN embedding BLOB`); + } + } catch { /* ignore */ } + } + return _db; } @@ -87,6 +190,196 @@ async function getSearchEngine(): Promise { return _searchEngine; } +// ===== Session Hybrid Search ===== + +/** + * Cosine similarity between two Float32Arrays + */ +function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) return 0; + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +/** + * Extract text to embed for a session table row + */ +function getSessionEmbeddingText( + table: 'observations' | 'user_prompts' | 'session_summaries', + row: Record +): string { + switch (table) { + case 'observations': { + const parts = [row.title, row.subtitle, row.narrative]; + try { + const concepts = JSON.parse((row.concepts as string) || '[]'); + if (concepts.length > 0) parts.push(concepts.join(', ')); + } catch { /* ignore */ } + return parts.filter(Boolean).join(' ').trim(); + } + case 'user_prompts': + return ((row.prompt_text as string) || '').trim(); + case 'session_summaries': { + const parts = [row.request, row.completed, row.next_steps, row.notes]; + return parts.filter(Boolean).join(' ').trim(); + } + } +} + +interface SessionSearchResult { + table: 'observations' | 'user_prompts' | 'session_summaries'; + id: string | number; + sessionId: string; + score: number; + keywordScore: number; + semanticScore: number; + time: number; + snippet: string; + data: Record; +} + +/** + * Hybrid search across all session tables (text + vector) + */ +async function searchSessionsHybrid( + db: BetterDatabase, + query: string, + options: { type?: 'hybrid' | 'text' | 'vector'; limit?: number } = {} +): Promise { + const { type = 'hybrid', limit = 30 } = options; + const results = new Map(); + const queryLower = query.toLowerCase(); + + // === Text search (LIKE) === + if (type === 'hybrid' || type === 'text') { + const pattern = `%${query}%`; + + // Observations + const obs = db.prepare(` + SELECT * FROM observations + WHERE title LIKE ? OR subtitle LIKE ? OR narrative LIKE ? OR tool_name LIKE ? + ORDER BY timestamp DESC LIMIT ? + `).all(pattern, pattern, pattern, pattern, limit) as Record[]; + for (const row of obs) { + const text = getSessionEmbeddingText('observations', row); + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`obs_${row.id}`, { + table: 'observations', id: row.id as string, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.timestamp as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + + // User prompts + const prompts = db.prepare(` + SELECT * FROM user_prompts WHERE prompt_text LIKE ? + ORDER BY created_at DESC LIMIT ? + `).all(pattern, limit) as Record[]; + for (const row of prompts) { + const text = (row.prompt_text as string) || ''; + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`prompt_${row.id}`, { + table: 'user_prompts', id: row.id as number, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.created_at as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + + // Session summaries + const summaries = db.prepare(` + SELECT * FROM session_summaries + WHERE request LIKE ? OR completed LIKE ? OR notes LIKE ? OR next_steps LIKE ? + ORDER BY created_at DESC LIMIT ? + `).all(pattern, pattern, pattern, pattern, limit) as Record[]; + for (const row of summaries) { + const text = getSessionEmbeddingText('session_summaries', row); + const idx = text.toLowerCase().indexOf(queryLower); + const kwScore = idx >= 0 ? Math.max(0.3, 1 - idx / 500) : 0.3; + results.set(`summary_${row.id}`, { + table: 'session_summaries', id: row.id as number, sessionId: row.session_id as string, + score: type === 'text' ? kwScore : kwScore * 0.3, + keywordScore: kwScore, semanticScore: 0, + time: row.created_at as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + } + + // === Vector search === + if ((type === 'hybrid' || type === 'vector') && query.trim()) { + try { + const embeddingsService = await getEmbeddingsService(); + const queryResult = await embeddingsService.embed(query); + const queryEmbedding = queryResult.embedding; + + const tables: Array<{ name: 'observations' | 'user_prompts' | 'session_summaries'; idCol: string; timeCol: string }> = [ + { name: 'observations', idCol: 'id', timeCol: 'timestamp' }, + { name: 'user_prompts', idCol: 'id', timeCol: 'created_at' }, + { name: 'session_summaries', idCol: 'id', timeCol: 'created_at' }, + ]; + + for (const { name, idCol, timeCol } of tables) { + const rows = db.prepare( + `SELECT * FROM ${name} WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0 ORDER BY ${timeCol} DESC LIMIT 2000` + ).all() as Record[]; + + for (const row of rows) { + const embBuffer = row.embedding as Buffer; + if (!embBuffer || embBuffer.length === 0) continue; + const embedding = new Float32Array( + embBuffer.buffer.slice(embBuffer.byteOffset, embBuffer.byteOffset + embBuffer.byteLength) + ); + const sim = cosineSimilarity(queryEmbedding, embedding); + if (sim < 0.1) continue; + + const prefix = name === 'observations' ? 'obs' : name === 'user_prompts' ? 'prompt' : 'summary'; + const key = `${prefix}_${row[idCol]}`; + const existing = results.get(key); + + if (existing) { + existing.semanticScore = sim; + existing.score = existing.keywordScore * 0.3 + sim * 0.7; + } else { + const text = getSessionEmbeddingText(name, row); + results.set(key, { + table: name, + id: row[idCol] as string | number, + sessionId: row.session_id as string, + score: type === 'vector' ? sim : sim * 0.7, + keywordScore: 0, semanticScore: sim, + time: row[timeCol] as number, + snippet: text.substring(0, 120), + data: { ...row, embedding: undefined }, + }); + } + } + } + } catch { + // Embeddings not available, fall back to text-only results + } + } + + return Array.from(results.values()) + .filter(r => r.score >= 0.05) + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + /** * Get database statistics using direct SQL (faster for stats queries) */ @@ -984,7 +1277,7 @@ function getHTML(): string {

AgentKits Memory Database

-
+
+ @@ -1039,9 +1342,25 @@ function getHTML(): string {
@@ -1653,6 +1972,8 @@ function getHTML(): string { function switchTab(tab) { document.getElementById('memories-tab').style.display = tab === 'memories' ? '' : 'none'; document.getElementById('sessions-tab').style.display = tab === 'sessions' ? '' : 'none'; + document.getElementById('header-actions-memories').style.display = tab === 'memories' ? '' : 'none'; + document.getElementById('header-actions-sessions').style.display = tab === 'sessions' ? '' : 'none'; document.querySelectorAll('.tab-btn').forEach(btn => { btn.style.borderBottomColor = 'transparent'; @@ -1665,96 +1986,203 @@ function getHTML(): string { if (tab === 'sessions') loadSessions(); } - // Sessions feed + // Sessions search + let sessionSearchQuery = ''; + let sessionSearchTimer = null; + function debounceSessionSearch() { + clearTimeout(sessionSearchTimer); + sessionSearchTimer = setTimeout(() => { + sessionSearchQuery = document.getElementById('session-search-input').value.trim(); + loadSessions(); + }, 300); + } + + // Session embedding management + async function loadSessionEmbeddingStats() { + try { + const res = await fetch('/api/sessions/embeddings/stats'); + const stats = await res.json(); + const el = document.getElementById('session-emb-stats'); + let totalAll = 0, totalWithEmb = 0; + const parts = []; + for (const [table, s] of Object.entries(stats)) { + const label = table === 'observations' ? 'Obs' : table === 'user_prompts' ? 'Prompts' : 'Summaries'; + totalAll += s.total; + totalWithEmb += s.withEmbedding; + const missing = s.total - s.withEmbedding; + const badge = missing > 0 + ? '' + s.withEmbedding + '/' + s.total + '' + : '' + s.total + ''; + parts.push(label + ': ' + badge); + } + const missingTotal = totalAll - totalWithEmb; + const status = missingTotal > 0 + ? '' + missingTotal + ' missing' + : 'All indexed'; + el.innerHTML = 'Vector index: ' + parts.join(' · ') + ' — ' + status; + } catch { /* ignore */ } + } + + async function generateSessionEmbeddings(mode) { + const el = document.getElementById('session-emb-stats'); + el.innerHTML = 'Generating embeddings...'; + try { + const res = await fetch('/api/sessions/embeddings/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: mode || 'missing' }), + }); + const result = await res.json(); + if (typeof showToast === 'function') { + showToast(result.success + ' embeddings generated', 'success'); + } + loadSessionEmbeddingStats(); + } catch (err) { + el.innerHTML = 'Error: ' + err.message + ''; + } + } + + // Sessions feed with pagination + const sessionPageSize = 30; + let sessionPage = 0; + + function sessionPrevPage() { if (sessionPage > 0) { sessionPage--; loadSessions(); } } + function sessionNextPage() { sessionPage++; loadSessions(); } + async function loadSessions() { const feed = document.getElementById('sessions-feed'); const statsEl = document.getElementById('sessions-stats'); + const paginationEl = document.getElementById('session-pagination'); + loadSessionEmbeddingStats(); + try { - const [sessRes, obsRes] = await Promise.all([ - fetch('/api/sessions?limit=20'), - fetch('/api/observations?limit=50') - ]); - const data = await sessRes.json(); - const observations = await obsRes.json(); - - // Stats - statsEl.innerHTML = \` -
-
Sessions
-
\${data.sessions?.length || 0}
-
-
-
User Prompts
-
\${data.prompts?.length || 0}
-
-
-
Summaries
-
\${data.summaries?.length || 0}
-
-
-
Observations
-
\${observations?.length || 0}
-
- \`; + let items = []; + let totalItems = 0; - // Build timeline feed (mix prompts, summaries, observations) - const items = []; - - for (const p of (data.prompts || [])) { - items.push({ - type: 'prompt', - time: p.created_at, - sessionId: p.session_id, - promptNumber: p.prompt_number, - text: p.prompt_text, - project: p.project, + if (sessionSearchQuery) { + // Use hybrid search endpoint (no pagination for search — returns ranked results) + const searchType = document.getElementById('session-search-type').value; + const params = new URLSearchParams({ + q: sessionSearchQuery, + type: searchType, + limit: String(sessionPageSize), }); - } + const res = await fetch('/api/sessions/search?' + params); + const results = await res.json(); - for (const s of (data.summaries || [])) { - items.push({ - type: 'summary', - time: s.created_at, - sessionId: s.session_id, - request: s.request, - completed: s.completed, - filesModified: s.files_modified, - nextSteps: s.next_steps, - notes: s.notes, - project: s.project, - }); - } + statsEl.innerHTML = \` +
+
Search Results
+
\${results.length}
+
+ \`; - for (const o of observations.slice(0, 30)) { - items.push({ - type: 'observation', - time: o.timestamp, - sessionId: o.session_id, - toolName: o.tool_name, - title: o.title, - obsType: o.type, - promptNumber: o.prompt_number, - }); + // Map search results to timeline items (already have hasEmbedding from data) + for (const r of results) { + const d = r.data || {}; + const hasEmb = !!(d.hasEmbedding || (r.semanticScore && r.semanticScore > 0)); + if (r.table === 'observations') { + items.push({ + type: 'observation', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + toolName: d.tool_name, title: d.title, obsType: d.type, promptNumber: d.prompt_number, + subtitle: d.subtitle, narrative: d.narrative, + }); + } else if (r.table === 'user_prompts') { + items.push({ + type: 'prompt', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + promptNumber: d.prompt_number, text: d.prompt_text, + }); + } else if (r.table === 'session_summaries') { + items.push({ + type: 'summary', time: r.time, sessionId: r.sessionId, score: r.score, hasEmbedding: hasEmb, + request: d.request, completed: d.completed, filesModified: d.files_modified, + nextSteps: d.next_steps, notes: d.notes, + }); + } + } + totalItems = results.length; + paginationEl.innerHTML = ''; + } else { + // Browse mode with pagination + const offset = sessionPage * sessionPageSize; + const [sessRes, obsRes] = await Promise.all([ + fetch('/api/sessions?limit=' + sessionPageSize + '&offset=' + offset), + fetch('/api/observations?limit=' + sessionPageSize + '&offset=' + offset) + ]); + const data = await sessRes.json(); + const observations = await obsRes.json(); + + statsEl.innerHTML = \` +
+
Sessions
+
\${data.sessions?.length || 0}
+
+
+
User Prompts
+
\${data.prompts?.length || 0}
+
+
+
Summaries
+
\${data.summaries?.length || 0}
+
+
+
Observations
+
\${observations?.length || 0}
+
+ \`; + + for (const p of (data.prompts || [])) { + items.push({ type: 'prompt', time: p.created_at, sessionId: p.session_id, + promptNumber: p.prompt_number, text: p.prompt_text, project: p.project, + hasEmbedding: !!p.hasEmbedding }); + } + for (const s of (data.summaries || [])) { + items.push({ type: 'summary', time: s.created_at, sessionId: s.session_id, + request: s.request, completed: s.completed, filesModified: s.files_modified, + nextSteps: s.next_steps, notes: s.notes, project: s.project, + hasEmbedding: !!s.hasEmbedding }); + } + for (const o of observations) { + items.push({ type: 'observation', time: o.timestamp, sessionId: o.session_id, + toolName: o.tool_name, title: o.title, obsType: o.type, promptNumber: o.prompt_number, + hasEmbedding: !!o.hasEmbedding }); + } + totalItems = items.length; + + // Render pagination + const hasMore = observations.length === sessionPageSize || (data.prompts || []).length === sessionPageSize; + paginationEl.innerHTML = \` + + Page \${sessionPage + 1} + + \`; } items.sort((a, b) => (b.time || 0) - (a.time || 0)); if (items.length === 0) { - feed.innerHTML = '
No session data yet. Hook data will appear here after sessions run.
'; + feed.innerHTML = '
' + + (sessionSearchQuery ? 'No results for "' + escapeHtml(sessionSearchQuery) + '"' : 'No session data yet. Hook data will appear here after sessions run.') + '
'; return; } feed.innerHTML = items.map(item => { const time = new Date(item.time).toLocaleString(); const sid = (item.sessionId || '').substring(0, 8); + const scoreBadge = item.score !== undefined + ? '' + (item.score * 100).toFixed(0) + '%' + : ''; + const vecBadge = item.hasEmbedding + ? 'Vec' + : '--'; if (item.type === 'prompt') { return \`
PROMPT #\${item.promptNumber || '?'} - +
\${scoreBadge}\${vecBadge}
-
\${escapeHtml(item.text || '')}
+
\${escapeHtml(truncate(item.text, 300))}
\`; } @@ -1764,13 +2192,13 @@ function getHTML(): string { return \`
SUMMARY - +
\${scoreBadge}\${vecBadge}
- \${item.request ? '
Request: ' + escapeHtml(item.request) + '
' : ''} - \${item.completed ? '
Completed: ' + escapeHtml(item.completed) + '
' : ''} - \${filesStr ? '
Files: ' + escapeHtml(filesStr) + '
' : ''} - \${item.nextSteps ? '
Next: ' + escapeHtml(item.nextSteps) + '
' : ''} + \${item.request ? '
Request: ' + escapeHtml(truncate(item.request, 250)) + '
' : ''} + \${item.completed ? '
Completed: ' + escapeHtml(truncate(item.completed, 250)) + '
' : ''} + \${filesStr ? '
Files: ' + escapeHtml(truncate(filesStr, 200)) + '
' : ''} + \${item.nextSteps ? '
Next: ' + escapeHtml(truncate(item.nextSteps, 200)) + '
' : ''}
\`; } @@ -1778,10 +2206,11 @@ function getHTML(): string { // observation const icons = { read: '📖', write: '✏️', execute: '⚡', search: '🔍' }; const icon = icons[item.obsType] || '•'; + const subtitle = item.subtitle ? ' - ' + escapeHtml(truncate(item.subtitle, 120)) : ''; return \`
-
- \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(item.title || '')} - \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''} +
+ \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(truncate(item.title, 100))}\${subtitle}\${scoreBadge} + \${vecBadge} \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''}
\`; }).join(''); @@ -1791,6 +2220,11 @@ function getHTML(): string { } } + function truncate(text, max = 200) { + if (!text || text.length <= max) return text || ''; + return text.substring(0, max) + '…'; + } + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -2109,31 +2543,149 @@ function handleRequest( return; } + // GET session hybrid search + if (url.pathname === '/api/sessions/search' && method === 'GET') { + const query = url.searchParams.get('q') || ''; + const searchType = (url.searchParams.get('type') || 'hybrid') as 'hybrid' | 'text' | 'vector'; + const limit = parseInt(url.searchParams.get('limit') || '30', 10); + + searchSessionsHybrid(db, query, { type: searchType, limit }) + .then((results) => { + res.writeHead(200); + res.end(JSON.stringify(results)); + }) + .catch((error) => { + res.writeHead(500); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Search failed' })); + }); + return; + } + + // GET session embeddings stats + if (url.pathname === '/api/sessions/embeddings/stats' && method === 'GET') { + try { + const stats: Record = {}; + for (const table of ['observations', 'user_prompts', 'session_summaries'] as const) { + const total = (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c; + const withEmb = (db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE embedding IS NOT NULL AND LENGTH(embedding) > 0`).get() as { c: number }).c; + stats[table] = { total, withEmbedding: withEmb }; + } + res.writeHead(200); + res.end(JSON.stringify(stats)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // POST generate session embeddings + if (url.pathname === '/api/sessions/embeddings/generate' && method === 'POST') { + readBody(req).then(async (body) => { + try { + const opts = JSON.parse(body || '{}') as { mode?: 'missing' | 'all' }; + const mode = opts.mode || 'missing'; + const embeddingsService = await getEmbeddingsService(); + + let totalSuccess = 0, totalFailed = 0; + const tableConfigs = [ + { name: 'observations' as const, idCol: 'id' }, + { name: 'user_prompts' as const, idCol: 'id' }, + { name: 'session_summaries' as const, idCol: 'id' }, + ]; + + for (const { name, idCol } of tableConfigs) { + const where = mode === 'missing' ? 'WHERE embedding IS NULL OR LENGTH(embedding) = 0' : ''; + const rows = db.prepare(`SELECT * FROM ${name} ${where}`).all() as Record[]; + const updateStmt = db.prepare(`UPDATE ${name} SET embedding = ? WHERE ${idCol} = ?`); + + for (const row of rows) { + const text = getSessionEmbeddingText(name, row); + if (!text) { totalFailed++; continue; } + try { + const result = await embeddingsService.embed(text); + const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + updateStmt.run(buffer, row[idCol]); + totalSuccess++; + } catch { totalFailed++; } + } + } + + res.writeHead(200); + res.end(JSON.stringify({ + processed: totalSuccess + totalFailed, + success: totalSuccess, + failed: totalFailed, + })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Generation failed' })); + } + }).catch(() => { + res.writeHead(400); + res.end(JSON.stringify({ error: 'Invalid request body' })); + }); + return; + } + // GET sessions data (sessions, prompts, summaries) - all in memory.db now if (url.pathname === '/api/sessions' && method === 'GET') { const limit = parseInt(url.searchParams.get('limit') || '20', 10); + const offset = parseInt(url.searchParams.get('offset') || '0', 10); + const query = url.searchParams.get('q') || ''; + + // Strip embedding BLOBs and add hasEmbedding flag + const stripEmb = (rows: Record[]) => + rows.map(r => ({ ...r, hasEmbedding: !!(r.embedding && (r.embedding as Buffer).length > 0), embedding: undefined })); try { - const sessions = db.prepare(` - SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? - `).all(limit) as Record[]; + let sessions: Record[]; + if (query) { + const pattern = `%${query}%`; + sessions = db.prepare(` + SELECT * FROM sessions WHERE session_id LIKE ? OR project LIKE ? OR prompt LIKE ? OR summary LIKE ? + ORDER BY started_at DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } else { + sessions = db.prepare(` + SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]; + } - // user_prompts may not exist in older DBs + // user_prompts let prompts: Record[] = []; try { - prompts = db.prepare(` - SELECT up.*, s.project FROM user_prompts up - JOIN sessions s ON s.session_id = up.session_id - ORDER BY up.created_at DESC LIMIT ? - `).all(limit) as Record[]; + if (query) { + const pattern = `%${query}%`; + prompts = stripEmb(db.prepare(` + SELECT up.*, s.project FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE up.prompt_text LIKE ? + ORDER BY up.created_at DESC LIMIT ? OFFSET ? + `).all(pattern, limit, offset) as Record[]); + } else { + prompts = stripEmb(db.prepare(` + SELECT up.*, s.project FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + ORDER BY up.created_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]); + } } catch { /* table may not exist */ } - // session_summaries may not exist in older DBs + // session_summaries let summaries: Record[] = []; try { - summaries = db.prepare(` - SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT ? - `).all(limit) as Record[]; + if (query) { + const pattern = `%${query}%`; + summaries = stripEmb(db.prepare(` + SELECT * FROM session_summaries WHERE request LIKE ? OR completed LIKE ? OR notes LIKE ? OR next_steps LIKE ? + ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]); + } else { + summaries = stripEmb(db.prepare(` + SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]); + } } catch { /* table may not exist */ } res.writeHead(200); @@ -2148,21 +2700,40 @@ function handleRequest( // GET observations from memory.db if (url.pathname === '/api/observations' && method === 'GET') { const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const offset = parseInt(url.searchParams.get('offset') || '0', 10); const sessionId = url.searchParams.get('session_id') || undefined; + const query = url.searchParams.get('q') || ''; + + // Strip embedding BLOBs and add hasEmbedding flag + const stripEmb = (rows: Record[]) => + rows.map(r => ({ ...r, hasEmbedding: !!(r.embedding && (r.embedding as Buffer).length > 0), embedding: undefined })); try { let rows: Record[]; - if (sessionId) { + if (query) { + const pattern = `%${query}%`; + if (sessionId) { + rows = db.prepare(` + SELECT * FROM observations WHERE session_id = ? AND (tool_name LIKE ? OR title LIKE ? OR subtitle LIKE ? OR narrative LIKE ?) + ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(sessionId, pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } else { + rows = db.prepare(` + SELECT * FROM observations WHERE tool_name LIKE ? OR title LIKE ? OR subtitle LIKE ? OR narrative LIKE ? + ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(pattern, pattern, pattern, pattern, limit, offset) as Record[]; + } + } else if (sessionId) { rows = db.prepare(` - SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp DESC LIMIT ? - `).all(sessionId, limit) as Record[]; + SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(sessionId, limit, offset) as Record[]; } else { rows = db.prepare(` - SELECT * FROM observations ORDER BY timestamp DESC LIMIT ? - `).all(limit) as Record[]; + SELECT * FROM observations ORDER BY timestamp DESC LIMIT ? OFFSET ? + `).all(limit, offset) as Record[]; } res.writeHead(200); - res.end(JSON.stringify(rows)); + res.end(JSON.stringify(stripEmb(rows))); } catch { res.writeHead(200); res.end(JSON.stringify([])); diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index b8f6ba8..6c68a46 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -2,7 +2,7 @@ * Unit Tests for AI Enrichment Module * * Tests the enrichment logic, env toggle, fallback behavior, - * parseAIResponse, buildExtractionPrompt, and mock SDK flow. + * parseAIResponse, buildExtractionPrompt, and mock CLI flow. * * @module @agentkits/memory/hooks/__tests__/ai-enrichment */ @@ -15,8 +15,11 @@ import { resetAIEnrichmentCache, parseAIResponse, buildExtractionPrompt, - _setQueryFunctionForTesting, - type QueryFunction, + parseSummaryResponse, + buildSummaryPrompt, + enrichSummaryWithAI, + _setRunClaudePrintMockForTesting, + _setCliAvailableForTesting, } from '../ai-enrichment.js'; describe('AI Enrichment Module', () => { @@ -32,7 +35,6 @@ describe('AI Enrichment Module', () => { } else { process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; } - _setQueryFunctionForTesting(null); resetAIEnrichmentCache(); }); @@ -52,7 +54,7 @@ describe('AI Enrichment Module', () => { it('should attempt enrichment when AGENTKITS_AI_ENRICHMENT=true', async () => { process.env.AGENTKITS_AI_ENRICHMENT = 'true'; const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); - // Returns enriched data if SDK available, null otherwise + // Returns enriched data if CLI available, null otherwise if (result !== null) { expect(typeof result.subtitle).toBe('string'); expect(typeof result.narrative).toBe('string'); @@ -64,7 +66,7 @@ describe('AI Enrichment Module', () => { it('should auto-detect when env not set', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', '{}'); - // Returns enriched data if SDK available, null otherwise + // Returns enriched data if CLI available, null otherwise if (result !== null) { expect(typeof result.subtitle).toBe('string'); expect(typeof result.narrative).toBe('string'); @@ -75,7 +77,7 @@ describe('AI Enrichment Module', () => { process.env.AGENTKITS_AI_ENRICHMENT = '1'; resetAIEnrichmentCache(); const result = await enrichWithAI('Read', '{}', '{}'); - // Returns enriched data if SDK available, null otherwise + // Returns enriched data if CLI available, null otherwise if (result !== null) { expect(typeof result.subtitle).toBe('string'); } @@ -122,10 +124,9 @@ describe('AI Enrichment Module', () => { expect(typeof available).toBe('boolean'); }); - it('should return true when mock query function is set', async () => { + it('should return true when CLI available mock is set', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn = createMockQueryFn('{}'); - _setQueryFunctionForTesting(mockFn); + _setCliAvailableForTesting(true); const available = await isAIEnrichmentAvailable(); expect(available).toBe(true); }); @@ -141,48 +142,16 @@ describe('AI Enrichment Module', () => { // Reset cache resetAIEnrichmentCache(); - // Now with auto-detect, result depends on SDK availability + // Now with auto-detect, result depends on CLI availability delete process.env.AGENTKITS_AI_ENRICHMENT; const result = await enrichWithAI('Read', '{}', '{}'); - // If SDK is available, returns enriched data; otherwise null + // If CLI is available, returns enriched data; otherwise null if (result !== null) { expect(typeof result.subtitle).toBe('string'); } }); }); - describe('_setQueryFunctionForTesting', () => { - it('should inject a mock query function', async () => { - delete process.env.AGENTKITS_AI_ENRICHMENT; - const validResponse = JSON.stringify({ - subtitle: 'Reading test file', - narrative: 'Read the test file to understand its contents.', - facts: ['File has 10 lines'], - concepts: ['testing'], - }); - const mockFn = createMockQueryFn(validResponse); - _setQueryFunctionForTesting(mockFn); - - const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', 'file contents'); - expect(result).not.toBeNull(); - expect(result!.subtitle).toBe('Reading test file'); - expect(result!.narrative).toBe('Read the test file to understand its contents.'); - expect(result!.facts).toEqual(['File has 10 lines']); - expect(result!.concepts).toEqual(['testing']); - }); - - it('should clear mock when set to null', async () => { - const mockFn = createMockQueryFn('{}'); - _setQueryFunctionForTesting(mockFn); - _setQueryFunctionForTesting(null); - - // After clearing, should fall back to SDK detection - const available = await isAIEnrichmentAvailable(); - // SDK not installed in test env - expect(typeof available).toBe('boolean'); - }); - }); - describe('buildExtractionPrompt', () => { it('should include tool name, input, and response', () => { const prompt = buildExtractionPrompt('Read', '{"file_path":"src/index.ts"}', 'file content here'); @@ -402,7 +371,11 @@ describe('AI Enrichment Module', () => { }); }); - describe('enrichWithAI with mock SDK', () => { + describe('enrichWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + it('should return enriched observation on success', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; const validResponse = JSON.stringify({ @@ -411,7 +384,7 @@ describe('AI Enrichment Module', () => { facts: ['File has 200 lines', 'Uses JWT tokens'], concepts: ['authentication', 'jwt', 'typescript'], }); - _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + _setRunClaudePrintMockForTesting(() => validResponse); const result = await enrichWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}'); expect(result).not.toBeNull(); @@ -420,157 +393,222 @@ describe('AI Enrichment Module', () => { expect(result!.concepts).toContain('jwt'); }); - it('should return null when SDK returns empty result', async () => { + it('should return null when CLI returns empty result', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - _setQueryFunctionForTesting(createMockQueryFn('')); + _setRunClaudePrintMockForTesting(() => null); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should return null when SDK returns invalid JSON', async () => { + it('should return null when CLI returns invalid JSON', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - _setQueryFunctionForTesting(createMockQueryFn('not valid json')); + _setRunClaudePrintMockForTesting(() => 'not valid json'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should return null when SDK returns incomplete structure', async () => { + it('should return null when CLI returns incomplete structure', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - _setQueryFunctionForTesting(createMockQueryFn('{"subtitle":"test"}')); + _setRunClaudePrintMockForTesting(() => '{"subtitle":"test"}'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should handle SDK stream with no result message', async () => { + it('should return null when mock returns empty string', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - // Mock that emits messages but none with type=result/subtype=success - const mockFn: QueryFunction = () => { - return (async function* () { - yield { type: 'progress', subtype: 'update' }; - yield { type: 'done', subtype: 'complete' }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + _setRunClaudePrintMockForTesting(() => ''); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should handle SDK stream with result but no text', async () => { + it('should return null when mock throws', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn: QueryFunction = () => { - return (async function* () { - yield { type: 'result', subtype: 'success', result: '' }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should handle SDK stream with result=undefined', async () => { - delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn: QueryFunction = () => { - return (async function* () { - yield { type: 'result', subtype: 'success' }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + it('should work with AGENTKITS_AI_ENRICHMENT=true and mock', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + const validResponse = JSON.stringify({ + subtitle: 'Running tests', + narrative: 'Executed test suite.', + facts: ['5 tests passed'], + concepts: ['testing'], + }); + _setRunClaudePrintMockForTesting(() => validResponse); - const result = await enrichWithAI('Read', '{}', '{}'); - expect(result).toBeNull(); + const result = await enrichWithAI('Bash', 'npm test', '5 passed'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Running tests'); }); - it('should return null when mock query function throws', async () => { - delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn: QueryFunction = () => { - throw new Error('SDK error'); - }; - _setQueryFunctionForTesting(mockFn); + it('should still return null when env=false even with mock set', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + _setRunClaudePrintMockForTesting(() => '{"subtitle":"Test","narrative":"Test.","facts":[],"concepts":[]}'); const result = await enrichWithAI('Read', '{}', '{}'); expect(result).toBeNull(); }); - it('should return null when mock query async iterator throws', async () => { + it('should parse markdown-fenced response from mock CLI', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn: QueryFunction = () => { - return (async function* () { - throw new Error('Stream error'); - })(); - }; - _setQueryFunctionForTesting(mockFn); + const fencedResponse = + '```json\n{"subtitle":"Fenced","narrative":"Fenced response.","facts":["f1"],"concepts":["c1"]}\n```'; + _setRunClaudePrintMockForTesting(() => fencedResponse); const result = await enrichWithAI('Read', '{}', '{}'); - expect(result).toBeNull(); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Fenced'); }); - it('should respect timeout with slow mock', async () => { + it('should pass prompt to mock', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - const mockFn: QueryFunction = () => { - return (async function* () { - // Simulate slow response - await new Promise((resolve) => setTimeout(resolve, 5000)); - yield { - type: 'result', - subtype: 'success', - result: '{"subtitle":"slow","narrative":"slow.","facts":[],"concepts":[]}', - }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + let capturedPrompt = ''; + _setRunClaudePrintMockForTesting((prompt) => { + capturedPrompt = prompt; + return JSON.stringify({ + subtitle: 'Test', + narrative: 'Test.', + facts: [], + concepts: [], + }); + }); - const start = Date.now(); - const result = await enrichWithAI('Read', '{}', '{}', 100); - const elapsed = Date.now() - start; + await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); + expect(capturedPrompt).toContain('Tool: Read'); + expect(capturedPrompt).toContain('test.ts'); + }); + }); + + describe('parseSummaryResponse', () => { + it('should parse valid summary JSON', () => { + const json = JSON.stringify({ + completed: 'Fixed a bug in the parser.', + nextSteps: 'Run integration tests.', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Fixed a bug in the parser.'); + expect(result!.nextSteps).toBe('Run integration tests.'); + }); + it('should accept nextSteps as array', () => { + const json = JSON.stringify({ + completed: 'Fixed a bug.', + nextSteps: ['Run tests', 'Deploy to staging'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps).toBe('Run tests; Deploy to staging'); + }); + + it('should default nextSteps to None when missing', () => { + const json = JSON.stringify({ + completed: 'All done.', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps).toBe('None'); + }); + + it('should return null when completed is not a string', () => { + const json = JSON.stringify({ + completed: 123, + nextSteps: 'Test', + }); + const result = parseSummaryResponse(json); expect(result).toBeNull(); - // Should resolve in ~100ms, not 5000ms - expect(elapsed).toBeLessThan(1000); }); - it('should work with AGENTKITS_AI_ENRICHMENT=true and mock', async () => { - process.env.AGENTKITS_AI_ENRICHMENT = 'true'; - const validResponse = JSON.stringify({ - subtitle: 'Running tests', - narrative: 'Executed test suite.', - facts: ['5 tests passed'], - concepts: ['testing'], + it('should truncate completed to 1000 chars', () => { + const json = JSON.stringify({ + completed: 'A'.repeat(1500), + nextSteps: 'Test', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed.length).toBe(1000); + }); + + it('should truncate nextSteps to 500 chars', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'B'.repeat(600), }); - _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.nextSteps.length).toBe(500); + }); - const result = await enrichWithAI('Bash', 'npm test', '5 passed'); + it('should strip markdown fences', () => { + const json = '```json\n{"completed":"Done.","nextSteps":"None"}\n```'; + const result = parseSummaryResponse(json); expect(result).not.toBeNull(); - expect(result!.subtitle).toBe('Running tests'); + expect(result!.completed).toBe('Done.'); }); - it('should still return null when env=false even with mock set', async () => { - process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + it('should return null for invalid JSON', () => { + expect(parseSummaryResponse('not json')).toBeNull(); + }); + }); + + describe('buildSummaryPrompt', () => { + it('should include template summary and assistant message', () => { + const prompt = buildSummaryPrompt('Request: Fix bug', 'I fixed the bug.'); + expect(prompt).toContain('Template Summary'); + expect(prompt).toContain('Request: Fix bug'); + expect(prompt).toContain('Last Assistant Message'); + expect(prompt).toContain('I fixed the bug.'); + }); + + it('should truncate long inputs', () => { + const longTemplate = 'T'.repeat(5000); + const longMessage = 'M'.repeat(5000); + const prompt = buildSummaryPrompt(longTemplate, longMessage); + // Should contain truncated versions (3000 chars each) + expect(prompt.length).toBeLessThan(10000); + }); + }); + + describe('enrichSummaryWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return enriched summary on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; const validResponse = JSON.stringify({ - subtitle: 'Test', - narrative: 'Test.', - facts: [], - concepts: [], + completed: 'Fixed the parser bug and verified with tests.', + nextSteps: 'None', }); - _setQueryFunctionForTesting(createMockQueryFn(validResponse)); + _setRunClaudePrintMockForTesting(() => validResponse); - const result = await enrichWithAI('Read', '{}', '{}'); + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed the parser.'); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Fixed the parser bug and verified with tests.'); + expect(result!.nextSteps).toBe('None'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); - it('should parse markdown-fenced response from mock SDK', async () => { + it('should return null when CLI returns invalid response', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; - const fencedResponse = - '```json\n{"subtitle":"Fenced","narrative":"Fenced response.","facts":["f1"],"concepts":["c1"]}\n```'; - _setQueryFunctionForTesting(createMockQueryFn(fencedResponse)); + _setRunClaudePrintMockForTesting(() => 'not json'); - const result = await enrichWithAI('Read', '{}', '{}'); - expect(result).not.toBeNull(); - expect(result!.subtitle).toBe('Fenced'); + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); }); }); @@ -579,7 +617,7 @@ describe('AI Enrichment Module', () => { delete process.env.AGENTKITS_AI_ENRICHMENT; // Should gracefully handle any input without throwing const result = await enrichWithAI('InvalidTool', 'not json', 'not json'); - // May return enriched data if SDK is available, or null if not + // May return enriched data if CLI is available, or null if not if (result !== null) { expect(typeof result.subtitle).toBe('string'); expect(typeof result.narrative).toBe('string'); @@ -596,19 +634,3 @@ describe('AI Enrichment Module', () => { }); }); }); - -/** - * Helper: Create a mock QueryFunction that yields a single result message - */ -function createMockQueryFn(resultText: string): QueryFunction { - return () => { - return (async function* () { - yield { - type: 'result', - subtype: 'success', - result: resultText, - total_cost_usd: 0.001, - }; - })(); - }; -} diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 35796ce..4f5b38e 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync, rmSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; -import { _setQueryFunctionForTesting, resetAIEnrichmentCache, type QueryFunction } from '../ai-enrichment.js'; +import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; const TEST_DIR = path.join(process.cwd(), '.test-memory-hooks'); @@ -474,7 +474,7 @@ describe('MemoryHookService', () => { }); afterEach(() => { - _setQueryFunctionForTesting(null); + _setRunClaudePrintMockForTesting(null); resetAIEnrichmentCache(); if (originalEnv === undefined) { delete process.env.AGENTKITS_AI_ENRICHMENT; @@ -498,12 +498,7 @@ describe('MemoryHookService', () => { facts: ['File has 200 lines', 'Uses JWT tokens'], concepts: ['authentication', 'jwt'], }); - const mockFn: QueryFunction = () => { - return (async function* () { - yield { type: 'result', subtype: 'success', result: validResponse }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + _setRunClaudePrintMockForTesting(() => validResponse); const result = await service.enrichObservation(obs.id); expect(result).toBe(true); @@ -528,13 +523,8 @@ describe('MemoryHookService', () => { 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); - // Mock AI that returns invalid response - const mockFn: QueryFunction = () => { - return (async function* () { - yield { type: 'result', subtype: 'success', result: 'not valid json' }; - })(); - }; - _setQueryFunctionForTesting(mockFn); + // Mock CLI that returns invalid response + _setRunClaudePrintMockForTesting(() => 'not valid json'); const result = await service.enrichObservation(obs.id); expect(result).toBe(false); @@ -551,11 +541,8 @@ describe('MemoryHookService', () => { 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR ); - // Mock AI that throws - const mockFn: QueryFunction = () => { - throw new Error('SDK error'); - }; - _setQueryFunctionForTesting(mockFn); + // Mock CLI that throws + _setRunClaudePrintMockForTesting(() => { throw new Error('CLI error'); }); const result = await service.enrichObservation(obs.id); expect(result).toBe(false); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index a153a1c..1fdd73a 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -1,13 +1,17 @@ /** - * AI Enrichment for Observations + * AI Enrichment for Observations and Session Summaries * - * Uses Claude Agent SDK (when available) to generate richer - * subtitle, narrative, facts, and concepts from tool observations. - * Falls back to template-based extraction when SDK is not available. + * Uses `claude --print` CLI to generate richer subtitle, narrative, + * facts, and concepts from tool observations, and to enhance session + * summaries using transcript data. This avoids the Claude Agent SDK's + * `query()` which creates visible sub-conversations in the Claude Code UI. + * Falls back to template-based extraction when `claude` CLI is not available. * * @module @agentkits/memory/hooks/ai-enrichment */ +import { execFileSync } from 'node:child_process'; + /** * Enriched observation data from AI extraction */ @@ -21,31 +25,21 @@ export interface EnrichedObservation { /** * Environment variable to enable/disable AI enrichment. * Set AGENTKITS_AI_ENRICHMENT=true to enable, false to disable. - * When not set, defaults to auto-detect (uses AI if SDK available). + * When not set, defaults to auto-detect (uses AI if CLI available). */ const AI_ENRICHMENT_ENV_KEY = 'AGENTKITS_AI_ENRICHMENT'; -/** Cached SDK availability */ -let _sdkAvailable: boolean | null = null; -let _queryFn: QueryFunction | null = null; +/** Cached CLI availability */ +let _cliAvailable: boolean | null = null; -/** Type for the SDK query function */ -export type QueryFunction = (params: { - prompt: string; - options: Record; -}) => AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - total_cost_usd?: number; - [key: string]: unknown; -}>; +/** Mock function for testing (replaces runClaudePrint when set) */ +let _mockRunClaudePrint: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null = null; /** * Check if AI enrichment is enabled via environment variable * - 'true' / '1' → force enable * - 'false' / '0' → force disable - * - not set → auto-detect (try SDK, fallback to template) + * - not set → auto-detect (try CLI, fallback to template) */ function isEnvEnabled(): boolean | null { const value = process.env[AI_ENRICHMENT_ENV_KEY]; @@ -56,35 +50,68 @@ function isEnvEnabled(): boolean | null { /** * Synchronous check: is AI enrichment potentially enabled? * Used by observation hook to decide whether to spawn background process. - * Does NOT check SDK availability (that's async). Just checks env var. + * Does NOT check CLI availability (that's async). Just checks env var. */ export function isAIEnrichmentEnabled(): boolean { const envEnabled = isEnvEnabled(); if (envEnabled === false) return false; // If explicitly enabled or auto-detect, optimistically return true. - // The background process will handle SDK availability check. + // The background process will handle CLI availability check. return true; } /** - * Check if Claude Agent SDK is available and cache the result + * Run a prompt through `claude --print` and return the raw text result. + * Uses --print mode which doesn't create a visible conversation. + * When a mock is set (testing), delegates to the mock instead. + */ +function runClaudePrint(prompt: string, systemPrompt: string, timeoutMs: number): string | null { + // Use mock if set (testing) + if (_mockRunClaudePrint) { + return _mockRunClaudePrint(prompt, systemPrompt, timeoutMs); + } + + try { + const result = execFileSync('claude', [ + '--print', + '--model', 'haiku', + '--system-prompt', systemPrompt, + '--max-turns', '1', + '--no-input', + '-p', prompt, + ], { + encoding: 'utf-8', + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'ignore'], // stdin pipe, stdout pipe, stderr ignore + }); + return result.trim() || null; + } catch { + return null; + } +} + +/** + * Check if `claude` CLI is available and cache the result. + * Env override (false/0) always takes priority over cache. */ -async function getQueryFunction(): Promise { - // Check env override first +function isClaudeCliAvailable(): boolean { + // Env override always wins — even over cached/mocked state const envEnabled = isEnvEnabled(); - if (envEnabled === false) return null; + if (envEnabled === false) return false; - if (_sdkAvailable === false) return null; - if (_queryFn) return _queryFn; + if (_cliAvailable !== null) return _cliAvailable; try { - const { query } = await import('@anthropic-ai/claude-agent-sdk'); - _queryFn = query as unknown as QueryFunction; - _sdkAvailable = true; - return _queryFn; + execFileSync('claude', ['--version'], { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'ignore'], + }); + _cliAvailable = true; + return true; } catch { - _sdkAvailable = false; - return null; + _cliAvailable = false; + return false; } } @@ -152,9 +179,10 @@ export function parseAIResponse(text: string): EnrichedObservation | null { } /** - * Enrich an observation using Claude Agent SDK + * Enrich an observation using `claude --print` CLI. * - * Returns enriched data if SDK is available and succeeds, + * Uses --print mode to avoid creating visible sub-conversations. + * Returns enriched data if CLI is available and succeeds, * or null to signal fallback to template-based extraction. */ export async function enrichWithAI( @@ -163,76 +191,146 @@ export async function enrichWithAI( toolResponse: string, timeoutMs: number = 15000 ): Promise { - const queryFn = await getQueryFunction(); - if (!queryFn) return null; + if (!isClaudeCliAvailable()) return null; try { const prompt = buildExtractionPrompt(toolName, toolInput, toolResponse); + const systemPrompt = 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.'; - // Race between AI query and timeout - const result = await Promise.race([ - executeQuery(queryFn, prompt), - new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)), - ]); - - return result; + const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseAIResponse(resultText); } catch { - // AI enrichment failed, caller should use template fallback return null; } } /** - * Execute the SDK query and extract the result + * Check if AI enrichment is available (`claude` CLI installed) */ -async function executeQuery( - queryFn: QueryFunction, - prompt: string -): Promise { - let resultText = ''; - - const stream = queryFn({ - prompt, - options: { - model: 'haiku', - systemPrompt: 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.', - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - maxTurns: 1, - allowedTools: [], // No tools needed, just text output - }, - }); - - for await (const message of stream) { - if (message.type === 'result' && message.subtype === 'success') { - resultText = message.result || ''; - } +export async function isAIEnrichmentAvailable(): Promise { + return isClaudeCliAvailable(); +} + +/** + * Reset cached CLI availability (for testing) + */ +export function resetAIEnrichmentCache(): void { + _cliAvailable = null; + _mockRunClaudePrint = null; +} + +/** + * Override CLI availability for testing (inject mock) + */ +export function _setCliAvailableForTesting(available: boolean): void { + _cliAvailable = available; +} + +/** + * Inject a mock for runClaudePrint (for testing). + * The mock receives (prompt, systemPrompt, timeoutMs) and returns string | null. + * Pass null to clear the mock. + */ +export function _setRunClaudePrintMockForTesting( + fn: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null +): void { + _mockRunClaudePrint = fn; + if (fn) { + _cliAvailable = true; // Mock implies CLI is "available" } +} + +// ===== Session Summary Enrichment ===== - if (!resultText) return null; - return parseAIResponse(resultText); +/** + * Enriched session summary data from AI extraction + */ +export interface EnrichedSummary { + completed: string; + nextSteps: string; } /** - * Check if AI enrichment is available (SDK installed) + * Build prompt for enriching a session summary using transcript context */ -export async function isAIEnrichmentAvailable(): Promise { - const queryFn = await getQueryFunction(); - return queryFn !== null; +export function buildSummaryPrompt( + templateSummary: string, + lastAssistantMessage: string +): string { + return `Analyze this Claude Code session and produce an enriched summary. + +## Template Summary (from observations) +${templateSummary.substring(0, 3000)} + +## Last Assistant Message (from transcript) +${lastAssistantMessage.substring(0, 3000)} + +Return ONLY a JSON object (no markdown, no code fences) with these fields: +{ + "completed": "Concise paragraph describing what was actually completed (2-4 sentences). Merge info from both the template summary and the assistant's final message.", + "nextSteps": "Concise list of remaining work or follow-up items, if any. Use 'None' if everything was completed." +}`; } /** - * Reset cached SDK availability (for testing) + * Parse enriched summary from AI response */ -export function resetAIEnrichmentCache(): void { - _sdkAvailable = null; - _queryFn = null; +export function parseSummaryResponse(text: string): EnrichedSummary | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + + // Handle completed: must be string + const completed = typeof parsed.completed === 'string' ? parsed.completed : null; + if (!completed) return null; + + // Handle nextSteps: accept string or array (AI often returns arrays for "list") + let nextSteps: string; + if (typeof parsed.nextSteps === 'string') { + nextSteps = parsed.nextSteps; + } else if (Array.isArray(parsed.nextSteps)) { + nextSteps = parsed.nextSteps.map((s: unknown) => String(s)).join('; '); + } else { + nextSteps = 'None'; + } + + return { + completed: completed.substring(0, 1000), + nextSteps: nextSteps.substring(0, 500), + }; + } catch { + return null; + } } /** - * Inject a mock query function (for testing only) + * Enrich a session summary using `claude --print` CLI. + * + * Takes template-based summary + last assistant message from transcript, + * returns AI-enhanced completed/nextSteps fields. + * Uses --print mode to avoid creating visible sub-conversations. */ -export function _setQueryFunctionForTesting(fn: QueryFunction | null): void { - _queryFn = fn; - _sdkAvailable = fn !== null; +export async function enrichSummaryWithAI( + templateSummary: string, + lastAssistantMessage: string, + timeoutMs: number = 20000 +): Promise { + if (!isClaudeCliAvailable()) return null; + + try { + const prompt = buildSummaryPrompt(templateSummary, lastAssistantMessage); + const systemPrompt = 'You are a session summary analyzer. Produce concise, accurate session summaries. Return only valid JSON.'; + + const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseSummaryResponse(resultText); + } catch { + return null; + } } diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 4cb18b6..ab6ca82 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -15,6 +15,7 @@ * summarize - Stop: generate session summary * user-message - SessionStart: display status to user (stderr) * enrich [cwd] - Background: AI-enrich a stored observation + * enrich-summary - Background: AI-enrich session summary * * @module @agentkits/memory/hooks/cli */ @@ -69,7 +70,7 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize, user-message, enrich'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session'); process.exit(1); } @@ -86,6 +87,35 @@ async function main(): Promise { process.exit(0); } + // Handle 'enrich-summary' command (no stdin, runs as background process) + if (event === 'enrich-summary') { + const sessionId = process.argv[3]; + const cwdArg = process.argv[4] || process.cwd(); + const transcriptPath = process.argv[5]; + if (sessionId && transcriptPath) { + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + await svc.enrichSessionSummary(sessionId, transcriptPath); + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'embed-session' command (no stdin, runs as background process) + // Processes the SQLite embedding queue + any records missing embeddings. + // Loads model once, processes sequentially with batch limit. Usage: embed-session + if (event === 'embed-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + await svc.processEmbeddingQueue(); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); @@ -126,11 +156,16 @@ async function main(): Promise { console.log(formatResponse(result)); } catch (error) { - // Log error to stderr + // Log error to stderr (visible in verbose mode with exit 0) console.error('[AgentKits Memory] CLI error:', error); - // Output standard response (don't block Claude) + // Output standard response so Claude can continue console.log(JSON.stringify(STANDARD_RESPONSE)); + + // MUST exit 0: exit code 2 would block UserPromptSubmit (erases prompt) + // and Stop (prevents Claude from stopping). Memory errors should never + // disrupt Claude's operation. + process.exit(0); } } diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 74e2f16..d8ffc33 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -7,7 +7,8 @@ * @module @agentkits/memory/hooks/service */ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { spawn } from 'node:child_process'; import * as path from 'node:path'; import Database from 'better-sqlite3'; import type { Database as BetterDatabase } from 'better-sqlite3'; @@ -27,7 +28,7 @@ import { extractConcepts, truncate, } from './types.js'; -import { enrichWithAI } from './ai-enrichment.js'; +import { enrichWithAI, enrichSummaryWithAI } from './ai-enrichment.js'; /** * Memory Hook Service Configuration @@ -91,6 +92,8 @@ export class MemoryHookService { // Enable WAL mode for better performance this.db.pragma('journal_mode = WAL'); + // Prevent SQLITE_BUSY when concurrent processes access the DB + this.db.pragma('busy_timeout = 10000'); // Create schema this.createSchema(); @@ -156,13 +159,20 @@ export class MemoryHookService { const promptNumber = this.getPromptNumber(sessionId) + 1; const now = Date.now(); - this.db!.prepare(` + const result = this.db!.prepare(` INSERT OR IGNORE INTO user_prompts (session_id, prompt_number, prompt_text, created_at) VALUES (?, ?, ?, ?) `).run(sessionId, promptNumber, promptText, now); + const id = result.changes > 0 ? Number(result.lastInsertRowid) : 0; + + // Queue embedding generation if insert succeeded + if (id > 0) { + this.queueSessionEmbedding('user_prompts', id); + } + return { - id: 0, // not needed for return + id, sessionId, promptNumber, promptText, @@ -313,6 +323,9 @@ export class MemoryHookService { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts)); + // Queue embedding generation + this.queueSessionEmbedding('observations', id); + // Update session observation count this.db!.prepare(` UPDATE sessions @@ -373,6 +386,198 @@ export class MemoryHookService { return true; } + /** + * Build embedding text for a session record based on table type. + */ + private getSessionEmbeddingText( + table: 'observations' | 'user_prompts' | 'session_summaries', + row: Record + ): string { + if (table === 'observations') { + const parts = [row.title, row.subtitle, row.narrative]; + try { + const concepts = JSON.parse((row.concepts as string) || '[]'); + if (concepts.length > 0) parts.push(concepts.join(', ')); + } catch { /* ignore */ } + return (parts.filter(Boolean) as string[]).join(' ').trim(); + } else if (table === 'user_prompts') { + return ((row.prompt_text as string) || '').trim(); + } else { + const parts = [row.request, row.completed, row.next_steps, row.notes]; + return (parts.filter(Boolean) as string[]).join(' ').trim(); + } + } + + // ===== Embedding Queue + Worker ===== + + /** Max records to process per worker invocation */ + private static readonly EMBED_BATCH_LIMIT = 200; + + /** + * Queue a session record for embedding generation. + * Inserts into SQLite embedding_queue table — atomic, no file I/O. + * Called from hook handlers — non-blocking, no model loading. + */ + queueSessionEmbedding( + table: 'observations' | 'user_prompts' | 'session_summaries', + recordId: string | number + ): void { + if (!this.db) return; + this.db.prepare( + 'INSERT INTO embedding_queue (target_table, target_id, created_at) VALUES (?, ?, ?)' + ).run(table, String(recordId), Date.now()); + } + + /** + * Spawn a detached embedding worker if not already running. + * Uses a lock file (PID-based) to prevent multiple concurrent workers. + */ + ensureEmbeddingWorkerRunning(cwd: string): void { + const lockFile = path.join(path.dirname(this.dbPath), 'embed-worker.lock'); + + // Check if worker is already running + if (existsSync(lockFile)) { + try { + const pid = parseInt(readFileSync(lockFile, 'utf-8').trim(), 10); + if (pid > 0) { + try { + process.kill(pid, 0); // signal 0 = check if alive + return; // Worker still running + } catch { + // Stale lock, remove + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } + } catch { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + } + + // Spawn detached worker + try { + const cliPath = path.resolve(cwd, 'dist/hooks/cli.js'); + const child = spawn('node', [cliPath, 'embed-session', cwd], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + child.unref(); + } catch { + // Silently ignore + } + } + + /** + * Process the embedding queue. Called by the worker process. + * Loads embedding model ONCE. Processes queued items + any DB records missing embeddings. + * Uses lock file to prevent concurrent workers. Respects EMBED_BATCH_LIMIT. + */ + async processEmbeddingQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'embed-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + const { LocalEmbeddingsService } = await import('../embeddings/local-embeddings.js'); + const cacheDir = path.join(path.dirname(this.dbPath), 'embeddings-cache'); + const embService = new LocalEmbeddingsService({ cacheDir }); + await embService.initialize(); + + const idColMap: Record = { + observations: 'id', + user_prompts: 'rowid', + session_summaries: 'rowid', + }; + + // Phase 1: Process queued items (claimed atomically) + while (count < MemoryHookService.EMBED_BATCH_LIMIT) { + // Claim next pending item + const item = this.db!.prepare( + "SELECT id, target_table, target_id FROM embedding_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1" + ).get() as { id: number; target_table: string; target_id: string } | undefined; + + if (!item) break; + + // Mark as processing + this.db!.prepare("UPDATE embedding_queue SET status = 'processing' WHERE id = ?").run(item.id); + + const idCol = idColMap[item.target_table]; + if (!idCol) { + this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + continue; + } + + try { + const row = this.db!.prepare( + `SELECT * FROM ${item.target_table} WHERE ${idCol} = ? AND embedding IS NULL` + ).get(item.target_id) as Record | undefined; + + if (!row) { + // Already embedded or doesn't exist — remove from queue + this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + continue; + } + + const text = this.getSessionEmbeddingText( + item.target_table as 'observations' | 'user_prompts' | 'session_summaries', row + ); + if (!text) { + this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + continue; + } + + const result = await embService.embed(text); + const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + this.db!.prepare(`UPDATE ${item.target_table} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, item.target_id); + this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + count++; + } catch { + // Mark as failed, will be retried on next worker run + this.db!.prepare("UPDATE embedding_queue SET status = 'pending' WHERE id = ?").run(item.id); + } + } + + // Phase 2: Catch up on any DB records missing embeddings (not in queue) + if (count < MemoryHookService.EMBED_BATCH_LIMIT) { + const tables = Object.entries(idColMap); + for (const [tableName, idCol] of tables) { + if (count >= MemoryHookService.EMBED_BATCH_LIMIT) break; + try { + const remaining = MemoryHookService.EMBED_BATCH_LIMIT - count; + const rows = this.db!.prepare( + `SELECT *, ${idCol} as _rid FROM ${tableName} WHERE embedding IS NULL ORDER BY rowid DESC LIMIT ?` + ).all(remaining) as Record[]; + + for (const row of rows) { + if (count >= MemoryHookService.EMBED_BATCH_LIMIT) break; + const text = this.getSessionEmbeddingText( + tableName as 'observations' | 'user_prompts' | 'session_summaries', row + ); + if (!text) continue; + + try { + const result = await embService.embed(text); + const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + this.db!.prepare(`UPDATE ${tableName} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, row._rid); + count++; + } catch { + // Skip + } + } + } catch { + // Table might not exist + } + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + /** * Get observations for a session */ @@ -666,9 +871,16 @@ export class MemoryHookService { now ); + const id = Number(result.lastInsertRowid); + + // Queue embedding generation + if (id > 0) { + this.queueSessionEmbedding('session_summaries', id); + } + return { ...summary, - id: Number(result.lastInsertRowid), + id, createdAt: now, }; } @@ -689,6 +901,50 @@ export class MemoryHookService { return rows.map(row => this.rowToSummary(row)); } + /** + * Enrich a session summary with AI using transcript data. + * Called from a background process after the template summary is saved. + * Reads the transcript JSONL, extracts last assistant message, + * then uses AI to enhance the completed/nextSteps fields. + */ + async enrichSessionSummary(sessionId: string, transcriptPath: string): Promise { + await this.ensureInitialized(); + + // Get existing summary from DB + const rows = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).all(sessionId) as Record[]; + + if (rows.length === 0) return false; + + const summary = this.rowToSummary(rows[0]); + + // Extract last assistant message from transcript + const lastMessage = extractLastAssistantMessage(transcriptPath); + if (!lastMessage) return false; + + // Build template summary text for AI context + const templateText = [ + summary.request ? `Request: ${summary.request}` : '', + summary.completed ? `Completed: ${summary.completed}` : '', + summary.filesModified.length > 0 ? `Files modified: ${summary.filesModified.join(', ')}` : '', + summary.notes ? `Notes: ${summary.notes}` : '', + ].filter(Boolean).join('\n'); + + // Call AI enrichment + const enriched = await enrichSummaryWithAI(templateText, lastMessage).catch(() => null); + if (!enriched) return false; + + // Update summary in-place + this.db!.prepare(` + UPDATE session_summaries + SET completed = ?, next_steps = ? + WHERE id = ? + `).run(enriched.completed, enriched.nextSteps, summary.id); + + return true; + } + private rowToSummary(row: Record): SessionSummary { return { id: row.id as number, @@ -749,6 +1005,7 @@ export class MemoryHookService { narrative TEXT, facts TEXT DEFAULT '[]', concepts TEXT DEFAULT '[]', + embedding BLOB, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) `); @@ -761,6 +1018,7 @@ export class MemoryHookService { prompt_number INTEGER NOT NULL, prompt_text TEXT NOT NULL, created_at INTEGER NOT NULL, + embedding BLOB, UNIQUE(session_id, prompt_number), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) @@ -780,10 +1038,22 @@ export class MemoryHookService { notes TEXT, prompt_number INTEGER, created_at INTEGER NOT NULL, + embedding BLOB, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) `); + // Embedding queue: holds pending embed requests processed by a single worker + this.db.exec(` + CREATE TABLE IF NOT EXISTS embedding_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + // Indexes this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_session ON observations(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_project ON observations(project)'); @@ -792,6 +1062,7 @@ export class MemoryHookService { this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_session ON user_prompts(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_project ON session_summaries(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_embed_queue_status ON embedding_queue(status)'); // Migration: add prompt_number to existing observations table this.migrateSchema(); @@ -822,6 +1093,14 @@ export class MemoryHookService { this.db.exec(sql); } } + + // Add embedding column to all session tables + for (const table of ['observations', 'user_prompts', 'session_summaries']) { + const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'embedding')) { + this.db.exec(`ALTER TABLE ${table} ADD COLUMN embedding BLOB`); + } + } } catch { // Ignore migration errors on fresh databases } @@ -897,4 +1176,58 @@ export function createHookService(cwd: string): MemoryHookService { return new MemoryHookService(cwd); } +/** + * Extract the last assistant message from a Claude Code transcript JSONL file. + * Reads the file, iterates lines in reverse, finds the last 'assistant' type entry, + * extracts text content, and strips tags. + */ +export function extractLastAssistantMessage(transcriptPath: string): string | null { + if (!transcriptPath || !existsSync(transcriptPath)) return null; + + try { + const content = readFileSync(transcriptPath, 'utf-8').trim(); + if (!content) return null; + + const lines = content.split('\n'); + + // Iterate in reverse to find the last assistant message + for (let i = lines.length - 1; i >= 0; i--) { + try { + const line = JSON.parse(lines[i]); + if (line.type !== 'assistant') continue; + + const msgContent = line.message?.content; + if (!msgContent) continue; + + let text = ''; + if (typeof msgContent === 'string') { + text = msgContent; + } else if (Array.isArray(msgContent)) { + // Extract text blocks from content array (skip tool_use blocks) + text = msgContent + .filter((c: { type: string }) => c.type === 'text') + .map((c: { text: string }) => c.text) + .join('\n'); + } + + if (!text) continue; + + // Strip tags + text = text.replace(/[\s\S]*?<\/system-reminder>/g, ''); + text = text.replace(/\n{3,}/g, '\n\n').trim(); + + // Return truncated to avoid excessive tokens + return text.substring(0, 5000); + } catch { + // Skip unparseable lines + continue; + } + } + + return null; + } catch { + return null; + } +} + export default MemoryHookService; diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index 77a366e..0e342e8 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -7,12 +7,15 @@ * @module @agentkits/memory/hooks/summarize */ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; import { NormalizedHookInput, HookResult, EventHandler, } from './types.js'; import { MemoryHookService } from './service.js'; +import { isAIEnrichmentEnabled } from './ai-enrichment.js'; /** * Summarize Hook - Stop Event @@ -66,6 +69,26 @@ export class SummarizeHook implements EventHandler { const textSummary = await this.service.generateSummary(input.sessionId); await this.service.completeSession(input.sessionId, textSummary); + // Ensure embedding worker is running to process queued items + this.service.ensureEmbeddingWorkerRunning(input.cwd); + + // Fire-and-forget: spawn detached process for AI summary enrichment + if (isAIEnrichmentEnabled() && input.transcriptPath) { + try { + const cliPath = path.resolve(input.cwd, 'dist/hooks/cli.js'); + const child = spawn('node', [ + cliPath, 'enrich-summary', input.sessionId, input.cwd, input.transcriptPath, + ], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }); + child.unref(); + } catch { + // Silently ignore — template summary already saved + } + } + // Shutdown service await this.service.shutdown(); diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 2f5b741..3f18964 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -338,7 +338,7 @@ export function getProjectName(cwd: string): string { */ export function getObservationType(toolName: string): ObservationType { const readTools = ['Read', 'Glob', 'Grep', 'LS']; - const writeTools = ['Write', 'Edit', 'NotebookEdit']; + const writeTools = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']; const executeTools = ['Bash', 'Task', 'Skill']; const searchTools = ['WebSearch', 'WebFetch']; @@ -388,6 +388,7 @@ export function generateObservationTitle(toolName: string, toolInput: unknown): case 'Write': return `Write ${input?.file_path || input?.path || 'file'}`; case 'Edit': + case 'MultiEdit': return `Edit ${input?.file_path || input?.path || 'file'}`; case 'Bash': const cmd = input?.command || ''; @@ -425,6 +426,7 @@ export function generateObservationSubtitle(toolName: string, toolInput: unknown case 'Write': return fileName ? `Creating/updating ${fileName}` : 'Writing file'; case 'Edit': + case 'MultiEdit': return fileName ? `Modifying ${fileName}` : 'Editing file'; case 'Bash': { const cmd = (input?.command || '').split(/\s+/)[0]; @@ -469,7 +471,8 @@ export function generateObservationNarrative( return `Read the contents of ${filePath || 'a file'} to understand the existing code structure.`; case 'Write': return `Wrote ${filePath || 'a file'} with new or updated content.`; - case 'Edit': { + case 'Edit': + case 'MultiEdit': { const oldStr = input?.old_string ? `"${input.old_string.substring(0, 40)}..."` : 'code'; return `Edited ${filePath || 'a file'}, replacing ${oldStr} with updated content.`; } @@ -520,6 +523,7 @@ export function extractFacts(toolName: string, toolInput: unknown, toolResponse: if (filePath) facts.push(`File created/updated: ${filePath}`); break; case 'Edit': + case 'MultiEdit': if (filePath) facts.push(`File modified: ${filePath}`); if (input?.old_string) facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || 'file'}`); break; From b7d26e8b19c8c66336a14e12b3348a1e205dfd66 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 07:01:19 +0900 Subject: [PATCH 04/21] feat: Refactor task management to use a unified task queue for embedding and enrichment processes --- src/cli/web-viewer.ts | 5 +- src/hooks/cli.ts | 49 +++++++++-- src/hooks/observation.ts | 19 +--- src/hooks/service.ts | 181 +++++++++++++++++++++++++++------------ src/hooks/summarize.ts | 11 ++- 5 files changed, 179 insertions(+), 86 deletions(-) diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index feb786a..6ad301e 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -133,10 +133,11 @@ function getDatabase(): BetterDatabase { ); `); - // Embedding queue table (shared with hooks service) + // Task queue table (shared with hooks service — used for embed + enrich workers) _db.exec(` - CREATE TABLE IF NOT EXISTS embedding_queue ( + CREATE TABLE IF NOT EXISTS task_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, target_table TEXT NOT NULL, target_id TEXT NOT NULL, created_at INTEGER NOT NULL, diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index ab6ca82..d6a32a6 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -16,11 +16,13 @@ * user-message - SessionStart: display status to user (stderr) * enrich [cwd] - Background: AI-enrich a stored observation * enrich-summary - Background: AI-enrich session summary + * embed-session - Background worker: process embedding queue + * enrich-session - Background worker: process enrichment queue * * @module @agentkits/memory/hooks/cli */ -import { parseHookInput, formatResponse, STANDARD_RESPONSE } from './types.js'; +import { parseHookInput, formatResponse, STANDARD_RESPONSE, HookResult } from './types.js'; import { createContextHook } from './context.js'; import { createSessionInitHook } from './session-init.js'; import { createObservationHook } from './observation.js'; @@ -70,7 +72,7 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session'); process.exit(1); } @@ -108,6 +110,10 @@ async function main(): Promise { const cwdArg = process.argv[3] || process.cwd(); const svc = new MemoryHookService(cwdArg); await svc.initialize(); + // Graceful shutdown on signals (cleanup lock file + DB) + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); try { await svc.processEmbeddingQueue(); } finally { @@ -116,6 +122,25 @@ async function main(): Promise { process.exit(0); } + // Handle 'enrich-session' command (no stdin, runs as background process) + // Processes the SQLite enrichment queue — calls claude --print for each observation. + // Usage: enrich-session + if (event === 'enrich-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + // Graceful shutdown on signals (cleanup lock file + DB) + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + try { + await svc.processEnrichmentQueue(); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); @@ -123,27 +148,28 @@ async function main(): Promise { const input = parseHookInput(stdin); // Select and execute handler - let result; + let result: HookResult | undefined; + let hook: { execute(input: ReturnType): Promise; shutdown(): Promise } | null = null; switch (event) { case 'context': - result = await createContextHook(input.cwd).execute(input); + hook = createContextHook(input.cwd); break; case 'session-init': - result = await createSessionInitHook(input.cwd).execute(input); + hook = createSessionInitHook(input.cwd); break; case 'observation': - result = await createObservationHook(input.cwd).execute(input); + hook = createObservationHook(input.cwd); break; case 'summarize': - result = await createSummarizeHook(input.cwd).execute(input); + hook = createSummarizeHook(input.cwd); break; case 'user-message': - result = await createUserMessageHook(input.cwd).execute(input); + hook = createUserMessageHook(input.cwd); break; default: @@ -152,6 +178,13 @@ async function main(): Promise { process.exit(0); } + // Execute hook with guaranteed shutdown (closes DB connection) + try { + result = await hook!.execute(input); + } finally { + try { await hook!.shutdown(); } catch { /* ignore shutdown errors */ } + } + // Output response console.log(formatResponse(result)); diff --git a/src/hooks/observation.ts b/src/hooks/observation.ts index 9100ac0..34479b0 100644 --- a/src/hooks/observation.ts +++ b/src/hooks/observation.ts @@ -7,15 +7,12 @@ * @module @agentkits/memory/hooks/observation */ -import { spawn } from 'node:child_process'; -import * as path from 'node:path'; import { NormalizedHookInput, HookResult, EventHandler, } from './types.js'; import { MemoryHookService } from './service.js'; -import { isAIEnrichmentEnabled } from './ai-enrichment.js'; /** * Tools to skip capturing (internal/noisy tools). @@ -111,20 +108,8 @@ export class ObservationHook implements EventHandler { input.cwd ); - // Fire-and-forget: spawn detached process for AI enrichment - if (isAIEnrichmentEnabled()) { - try { - const cliPath = path.resolve(input.cwd, 'dist/hooks/cli.js'); - const child = spawn('node', [cliPath, 'enrich', obs.id, input.cwd], { - detached: true, - stdio: 'ignore', - env: { ...process.env }, - }); - child.unref(); - } catch { - // Silently ignore — template data already saved - } - } + // Enrichment + embedding are queued in service.ts storeObservation() + // Workers are spawned at session end (summarize hook) return { continue: true, diff --git a/src/hooks/service.ts b/src/hooks/service.ts index d8ffc33..11f50bd 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -7,7 +7,7 @@ * @module @agentkits/memory/hooks/service */ -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, constants as fsConstants } from 'node:fs'; import { spawn } from 'node:child_process'; import * as path from 'node:path'; import Database from 'better-sqlite3'; @@ -168,7 +168,7 @@ export class MemoryHookService { // Queue embedding generation if insert succeeded if (id > 0) { - this.queueSessionEmbedding('user_prompts', id); + this.queueTask('embed', 'user_prompts', id); } return { @@ -323,8 +323,9 @@ export class MemoryHookService { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts)); - // Queue embedding generation - this.queueSessionEmbedding('observations', id); + // Queue background tasks (embedding + AI enrichment) + this.queueTask('embed', 'observations', id); + this.queueTask('enrich', 'observations', id); // Update session observation count this.db!.prepare(` @@ -411,31 +412,33 @@ export class MemoryHookService { // ===== Embedding Queue + Worker ===== /** Max records to process per worker invocation */ - private static readonly EMBED_BATCH_LIMIT = 200; + private static readonly WORKER_BATCH_LIMIT = 200; /** - * Queue a session record for embedding generation. - * Inserts into SQLite embedding_queue table — atomic, no file I/O. - * Called from hook handlers — non-blocking, no model loading. + * Queue a background task. Inserts into SQLite task_queue — atomic, <1ms. + * Called from hook handlers — non-blocking, no model/API loading. */ - queueSessionEmbedding( - table: 'observations' | 'user_prompts' | 'session_summaries', + queueTask( + taskType: 'embed' | 'enrich', + table: string, recordId: string | number ): void { if (!this.db) return; this.db.prepare( - 'INSERT INTO embedding_queue (target_table, target_id, created_at) VALUES (?, ?, ?)' - ).run(table, String(recordId), Date.now()); + 'INSERT INTO task_queue (task_type, target_table, target_id, created_at) VALUES (?, ?, ?, ?)' + ).run(taskType, table, String(recordId), Date.now()); } /** - * Spawn a detached embedding worker if not already running. - * Uses a lock file (PID-based) to prevent multiple concurrent workers. + * Spawn a detached background worker if not already running. + * Uses a PID-based lock file to prevent multiple concurrent workers. + * @param workerType - 'embed-session' or 'enrich-session' + * @param lockName - unique lock file name for this worker type */ - ensureEmbeddingWorkerRunning(cwd: string): void { - const lockFile = path.join(path.dirname(this.dbPath), 'embed-worker.lock'); + ensureWorkerRunning(cwd: string, workerType: string, lockName: string): void { + const lockFile = path.join(path.dirname(this.dbPath), lockName); - // Check if worker is already running + // Check if worker is already running (stale lock cleanup) if (existsSync(lockFile)) { try { const pid = parseInt(readFileSync(lockFile, 'utf-8').trim(), 10); @@ -444,33 +447,53 @@ export class MemoryHookService { process.kill(pid, 0); // signal 0 = check if alive return; // Worker still running } catch { - // Stale lock, remove + // Process dead — remove stale lock try { unlinkSync(lockFile); } catch { /* ignore */ } } + } else { + try { unlinkSync(lockFile); } catch { /* ignore */ } } } catch { try { unlinkSync(lockFile); } catch { /* ignore */ } } } - // Spawn detached worker + // Atomic lock acquisition: O_CREAT | O_EXCL fails if file exists (prevents race) + let fd: number; + try { + fd = openSync(lockFile, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY); + } catch { + // Another process created the lock between our check and open — that's fine + return; + } + + // Write PID placeholder (will be overwritten by worker with its actual PID) + try { + writeFileSync(lockFile, '0'); + closeSync(fd); + } catch { + try { closeSync(fd); } catch { /* ignore */ } + } + + // Spawn detached worker (worker writes its own PID to lock file on start) try { const cliPath = path.resolve(cwd, 'dist/hooks/cli.js'); - const child = spawn('node', [cliPath, 'embed-session', cwd], { + const child = spawn('node', [cliPath, workerType, cwd], { detached: true, stdio: 'ignore', env: { ...process.env }, }); child.unref(); } catch { - // Silently ignore + // Failed to spawn — clean up lock + try { unlinkSync(lockFile); } catch { /* ignore */ } } } /** - * Process the embedding queue. Called by the worker process. - * Loads embedding model ONCE. Processes queued items + any DB records missing embeddings. - * Uses lock file to prevent concurrent workers. Respects EMBED_BATCH_LIMIT. + * Process embedding tasks from the queue. + * Loads embedding model ONCE, processes queued items + DB catch-up. + * Uses lock file to prevent concurrent workers. */ async processEmbeddingQueue(): Promise { await this.ensureInitialized(); @@ -491,21 +514,26 @@ export class MemoryHookService { session_summaries: 'rowid', }; - // Phase 1: Process queued items (claimed atomically) - while (count < MemoryHookService.EMBED_BATCH_LIMIT) { - // Claim next pending item + // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions + const claimEmbedTask = this.db!.transaction(() => { const item = this.db!.prepare( - "SELECT id, target_table, target_id FROM embedding_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1" + "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'embed' AND status = 'pending' ORDER BY id ASC LIMIT 1" ).get() as { id: number; target_table: string; target_id: string } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); - if (!item) break; + // Phase 1: Process queued embed tasks + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimEmbedTask(); - // Mark as processing - this.db!.prepare("UPDATE embedding_queue SET status = 'processing' WHERE id = ?").run(item.id); + if (!item) break; const idCol = idColMap[item.target_table]; if (!idCol) { - this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); continue; } @@ -515,8 +543,7 @@ export class MemoryHookService { ).get(item.target_id) as Record | undefined; if (!row) { - // Already embedded or doesn't exist — remove from queue - this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); continue; } @@ -524,51 +551,93 @@ export class MemoryHookService { item.target_table as 'observations' | 'user_prompts' | 'session_summaries', row ); if (!text) { - this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); continue; } const result = await embService.embed(text); const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); this.db!.prepare(`UPDATE ${item.target_table} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, item.target_id); - this.db!.prepare('DELETE FROM embedding_queue WHERE id = ?').run(item.id); + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); count++; } catch { - // Mark as failed, will be retried on next worker run - this.db!.prepare("UPDATE embedding_queue SET status = 'pending' WHERE id = ?").run(item.id); + this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); } } - // Phase 2: Catch up on any DB records missing embeddings (not in queue) - if (count < MemoryHookService.EMBED_BATCH_LIMIT) { - const tables = Object.entries(idColMap); - for (const [tableName, idCol] of tables) { - if (count >= MemoryHookService.EMBED_BATCH_LIMIT) break; + // Phase 2: Catch up on DB records missing embeddings + if (count < MemoryHookService.WORKER_BATCH_LIMIT) { + for (const [tableName, idCol] of Object.entries(idColMap)) { + if (count >= MemoryHookService.WORKER_BATCH_LIMIT) break; try { - const remaining = MemoryHookService.EMBED_BATCH_LIMIT - count; + const remaining = MemoryHookService.WORKER_BATCH_LIMIT - count; const rows = this.db!.prepare( `SELECT *, ${idCol} as _rid FROM ${tableName} WHERE embedding IS NULL ORDER BY rowid DESC LIMIT ?` ).all(remaining) as Record[]; for (const row of rows) { - if (count >= MemoryHookService.EMBED_BATCH_LIMIT) break; + if (count >= MemoryHookService.WORKER_BATCH_LIMIT) break; const text = this.getSessionEmbeddingText( tableName as 'observations' | 'user_prompts' | 'session_summaries', row ); if (!text) continue; - try { const result = await embService.embed(text); const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); this.db!.prepare(`UPDATE ${tableName} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, row._rid); count++; - } catch { - // Skip - } + } catch { /* skip */ } } - } catch { - // Table might not exist + } catch { /* table might not exist */ } + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + + /** + * Process enrichment tasks from the queue. + * Calls claude --print sequentially for each observation. + * Uses lock file to prevent concurrent workers. + */ + async processEnrichmentQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'enrich-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions + const claimEnrichTask = this.db!.transaction(() => { + const item = this.db!.prepare( + "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' ORDER BY id ASC LIMIT 1" + ).get() as { id: number; target_table: string; target_id: string } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); + + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimEnrichTask(); + + if (!item) break; + + try { + if (item.target_table === 'observations') { + await this.enrichObservation(item.target_id); + } else if (item.target_table === 'session_summaries') { + // Summary enrichment needs transcript path — skip from queue + // (handled separately in summarize hook with direct spawn) } + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + count++; + } catch { + this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); } } } finally { @@ -875,7 +944,7 @@ export class MemoryHookService { // Queue embedding generation if (id > 0) { - this.queueSessionEmbedding('session_summaries', id); + this.queueTask('embed', 'session_summaries', id); } return { @@ -1043,10 +1112,12 @@ export class MemoryHookService { ) `); - // Embedding queue: holds pending embed requests processed by a single worker + // Task queue: holds pending background tasks (embedding, enrichment) + // processed by single-instance workers with lock files this.db.exec(` - CREATE TABLE IF NOT EXISTS embedding_queue ( + CREATE TABLE IF NOT EXISTS task_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, target_table TEXT NOT NULL, target_id TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -1062,7 +1133,7 @@ export class MemoryHookService { this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_session ON user_prompts(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_project ON session_summaries(project)'); - this.db.exec('CREATE INDEX IF NOT EXISTS idx_embed_queue_status ON embedding_queue(status)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status, task_type)'); // Migration: add prompt_number to existing observations table this.migrateSchema(); diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index 0e342e8..4d34e7e 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -69,10 +69,13 @@ export class SummarizeHook implements EventHandler { const textSummary = await this.service.generateSummary(input.sessionId); await this.service.completeSession(input.sessionId, textSummary); - // Ensure embedding worker is running to process queued items - this.service.ensureEmbeddingWorkerRunning(input.cwd); + // Spawn background workers to process queued tasks (one per type, gated by lock file) + this.service.ensureWorkerRunning(input.cwd, 'embed-session', 'embed-worker.lock'); + if (isAIEnrichmentEnabled()) { + this.service.ensureWorkerRunning(input.cwd, 'enrich-session', 'enrich-worker.lock'); + } - // Fire-and-forget: spawn detached process for AI summary enrichment + // Summary enrichment needs transcript path — handled separately (not via queue) if (isAIEnrichmentEnabled() && input.transcriptPath) { try { const cliPath = path.resolve(input.cwd, 'dist/hooks/cli.js'); @@ -85,7 +88,7 @@ export class SummarizeHook implements EventHandler { }); child.unref(); } catch { - // Silently ignore — template summary already saved + // Silently ignore } } From 76c73a9981e6b789a37d35abfed490ec7a966660 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 07:03:14 +0900 Subject: [PATCH 05/21] Add comprehensive tests for hook handlers and service queue worker - Implement tests to skip empty or no-op tool calls in hook handlers. - Add tests for spawning enrich workers when AI enrichment is enabled. - Create a new test suite for MemoryHookService covering task queue, worker lifecycle, session summaries, user prompts, and transcript extraction. - Enhance existing tests for generating narratives and extracting facts from various tools. - Ensure proper handling of edge cases in narrative generation and fact extraction. --- coverage-tmp/coverage-final.json | 10 + src/hooks/__tests__/ai-enrichment.test.ts | 23 + src/hooks/__tests__/handlers.test.ts | 97 ++ .../__tests__/service-queue-worker.test.ts | 981 ++++++++++++++++++ src/hooks/__tests__/types.test.ts | 96 ++ 5 files changed, 1207 insertions(+) create mode 100644 coverage-tmp/coverage-final.json create mode 100644 src/hooks/__tests__/service-queue-worker.test.ts diff --git a/coverage-tmp/coverage-final.json b/coverage-tmp/coverage-final.json new file mode 100644 index 0000000..4044f6f --- /dev/null +++ b/coverage-tmp/coverage-final.json @@ -0,0 +1,10 @@ +{"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/embeddings/local-embeddings.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/embeddings/local-embeddings.ts","statementMap":{"0":{"start":{"line":95,"column":18},"end":{"line":95,"column":null}},"1":{"start":{"line":96,"column":34},"end":{"line":96,"column":null}},"2":{"start":{"line":100,"column":4},"end":{"line":100,"column":null}},"3":{"start":{"line":104,"column":18},"end":{"line":104,"column":null}},"4":{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},"5":{"start":{"line":107,"column":20},"end":{"line":107,"column":null}},"6":{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},"7":{"start":{"line":109,"column":8},"end":{"line":109,"column":null}},"8":{"start":{"line":111,"column":6},"end":{"line":111,"column":null}},"9":{"start":{"line":113,"column":4},"end":{"line":113,"column":null}},"10":{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},"11":{"start":{"line":118,"column":6},"end":{"line":118,"column":null}},"12":{"start":{"line":119,"column":6},"end":{"line":119,"column":null}},"13":{"start":{"line":123,"column":4},"end":{"line":128,"column":null}},"14":{"start":{"line":124,"column":21},"end":{"line":124,"column":null}},"15":{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},"16":{"start":{"line":126,"column":8},"end":{"line":126,"column":null}},"17":{"start":{"line":130,"column":4},"end":{"line":130,"column":null}},"18":{"start":{"line":131,"column":4},"end":{"line":131,"column":null}},"19":{"start":{"line":135,"column":4},"end":{"line":135,"column":null}},"20":{"start":{"line":136,"column":4},"end":{"line":136,"column":null}},"21":{"start":{"line":140,"column":4},"end":{"line":140,"column":null}},"22":{"start":{"line":148,"column":20},"end":{"line":148,"column":null}},"23":{"start":{"line":150,"column":13},"end":{"line":150,"column":null}},"24":{"start":{"line":151,"column":2},"end":{"line":154,"column":null}},"25":{"start":{"line":151,"column":15},"end":{"line":151,"column":18}},"26":{"start":{"line":152,"column":4},"end":{"line":152,"column":null}},"27":{"start":{"line":153,"column":4},"end":{"line":153,"column":null}},"28":{"start":{"line":156,"column":2},"end":{"line":161,"column":null}},"29":{"start":{"line":156,"column":15},"end":{"line":156,"column":18}},"30":{"start":{"line":158,"column":4},"end":{"line":158,"column":null}},"31":{"start":{"line":159,"column":4},"end":{"line":159,"column":null}},"32":{"start":{"line":160,"column":4},"end":{"line":160,"column":null}},"33":{"start":{"line":164,"column":13},"end":{"line":164,"column":null}},"34":{"start":{"line":165,"column":2},"end":{"line":167,"column":null}},"35":{"start":{"line":165,"column":15},"end":{"line":165,"column":18}},"36":{"start":{"line":166,"column":4},"end":{"line":166,"column":null}},"37":{"start":{"line":168,"column":2},"end":{"line":168,"column":null}},"38":{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},"39":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"40":{"start":{"line":170,"column":17},"end":{"line":170,"column":20}},"41":{"start":{"line":171,"column":6},"end":{"line":171,"column":null}},"42":{"start":{"line":175,"column":2},"end":{"line":175,"column":null}},"43":{"start":{"line":186,"column":49},"end":{"line":186,"column":null}},"44":{"start":{"line":187,"column":26},"end":{"line":187,"column":null}},"45":{"start":{"line":188,"column":47},"end":{"line":188,"column":null}},"46":{"start":{"line":189,"column":18},"end":{"line":194,"column":null}},"47":{"start":{"line":197,"column":4},"end":{"line":206,"column":null}},"48":{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},"49":{"start":{"line":209,"column":6},"end":{"line":209,"column":null}},"50":{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},"51":{"start":{"line":218,"column":6},"end":{"line":218,"column":null}},"52":{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},"53":{"start":{"line":222,"column":6},"end":{"line":222,"column":null}},"54":{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},"55":{"start":{"line":226,"column":6},"end":{"line":226,"column":null}},"56":{"start":{"line":227,"column":6},"end":{"line":227,"column":null}},"57":{"start":{"line":230,"column":4},"end":{"line":230,"column":null}},"58":{"start":{"line":231,"column":4},"end":{"line":231,"column":null}},"59":{"start":{"line":235,"column":4},"end":{"line":261,"column":null}},"60":{"start":{"line":237,"column":27},"end":{"line":237,"column":null}},"61":{"start":{"line":239,"column":31},"end":{"line":249,"column":null}},"62":{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},"63":{"start":{"line":242,"column":14},"end":{"line":244,"column":null}},"64":{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},"65":{"start":{"line":246,"column":14},"end":{"line":246,"column":null}},"66":{"start":{"line":251,"column":6},"end":{"line":253,"column":null}},"67":{"start":{"line":256,"column":6},"end":{"line":259,"column":null}},"68":{"start":{"line":260,"column":6},"end":{"line":260,"column":null}},"69":{"start":{"line":268,"column":22},"end":{"line":268,"column":null}},"70":{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},"71":{"start":{"line":272,"column":21},"end":{"line":272,"column":null}},"72":{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},"73":{"start":{"line":274,"column":8},"end":{"line":274,"column":null}},"74":{"start":{"line":275,"column":8},"end":{"line":279,"column":null}},"75":{"start":{"line":281,"column":6},"end":{"line":281,"column":null}},"76":{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},"77":{"start":{"line":288,"column":6},"end":{"line":288,"column":null}},"78":{"start":{"line":290,"column":6},"end":{"line":290,"column":null}},"79":{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},"80":{"start":{"line":294,"column":8},"end":{"line":294,"column":null}},"81":{"start":{"line":296,"column":23},"end":{"line":299,"column":null}},"82":{"start":{"line":300,"column":8},"end":{"line":300,"column":null}},"83":{"start":{"line":304,"column":19},"end":{"line":304,"column":null}},"84":{"start":{"line":307,"column":4},"end":{"line":307,"column":null}},"85":{"start":{"line":308,"column":4},"end":{"line":308,"column":null}},"86":{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},"87":{"start":{"line":312,"column":6},"end":{"line":312,"column":null}},"88":{"start":{"line":315,"column":4},"end":{"line":319,"column":null}},"89":{"start":{"line":327,"column":4},"end":{"line":327,"column":null}},"90":{"start":{"line":327,"column":43},"end":{"line":327,"column":59}},"91":{"start":{"line":334,"column":4},"end":{"line":337,"column":null}},"92":{"start":{"line":335,"column":21},"end":{"line":335,"column":null}},"93":{"start":{"line":336,"column":6},"end":{"line":336,"column":null}},"94":{"start":{"line":344,"column":4},"end":{"line":355,"column":null}},"95":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"96":{"start":{"line":363,"column":6},"end":{"line":363,"column":null}},"97":{"start":{"line":371,"column":4},"end":{"line":371,"column":null}},"98":{"start":{"line":378,"column":4},"end":{"line":378,"column":null}},"99":{"start":{"line":379,"column":4},"end":{"line":379,"column":null}},"100":{"start":{"line":380,"column":4},"end":{"line":380,"column":null}},"101":{"start":{"line":390,"column":2},"end":{"line":390,"column":null}},"102":{"start":{"line":413,"column":18},"end":{"line":413,"column":null}},"103":{"start":{"line":414,"column":2},"end":{"line":414,"column":null}},"104":{"start":{"line":415,"column":2},"end":{"line":415,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":99,"column":2},"end":{"line":99,"column":14}},"loc":{"start":{"line":99,"column":31},"end":{"line":101,"column":null}},"line":99},"1":{"name":"(anonymous_1)","decl":{"start":{"line":103,"column":2},"end":{"line":103,"column":6}},"loc":{"start":{"line":103,"column":45},"end":{"line":114,"column":null}},"line":103},"2":{"name":"(anonymous_2)","decl":{"start":{"line":116,"column":2},"end":{"line":116,"column":6}},"loc":{"start":{"line":116,"column":46},"end":{"line":132,"column":null}},"line":116},"3":{"name":"(anonymous_3)","decl":{"start":{"line":134,"column":2},"end":{"line":134,"column":16}},"loc":{"start":{"line":134,"column":16},"end":{"line":137,"column":null}},"line":134},"4":{"name":"(anonymous_4)","decl":{"start":{"line":139,"column":6},"end":{"line":139,"column":21}},"loc":{"start":{"line":139,"column":21},"end":{"line":141,"column":null}},"line":139},"5":{"name":"createMockEmbedding","decl":{"start":{"line":147,"column":9},"end":{"line":147,"column":29}},"loc":{"start":{"line":147,"column":77},"end":{"line":176,"column":null}},"line":147},"6":{"name":"(anonymous_6)","decl":{"start":{"line":196,"column":2},"end":{"line":196,"column":14}},"loc":{"start":{"line":196,"column":50},"end":{"line":211,"column":null}},"line":196},"7":{"name":"(anonymous_7)","decl":{"start":{"line":216,"column":8},"end":{"line":216,"column":36}},"loc":{"start":{"line":216,"column":36},"end":{"line":232,"column":null}},"line":216},"8":{"name":"(anonymous_8)","decl":{"start":{"line":234,"column":16},"end":{"line":234,"column":43}},"loc":{"start":{"line":234,"column":43},"end":{"line":262,"column":null}},"line":234},"9":{"name":"(anonymous_9)","decl":{"start":{"line":240,"column":10},"end":{"line":240,"column":11}},"loc":{"start":{"line":240,"column":63},"end":{"line":248,"column":null}},"line":240},"10":{"name":"(anonymous_10)","decl":{"start":{"line":267,"column":8},"end":{"line":267,"column":14}},"loc":{"start":{"line":267,"column":54},"end":{"line":320,"column":null}},"line":267},"11":{"name":"(anonymous_11)","decl":{"start":{"line":325,"column":8},"end":{"line":325,"column":19}},"loc":{"start":{"line":325,"column":64},"end":{"line":328,"column":null}},"line":325},"12":{"name":"(anonymous_12)","decl":{"start":{"line":327,"column":33},"end":{"line":327,"column":34}},"loc":{"start":{"line":327,"column":43},"end":{"line":327,"column":59}},"line":327},"13":{"name":"(anonymous_13)","decl":{"start":{"line":333,"column":2},"end":{"line":333,"column":37}},"loc":{"start":{"line":333,"column":37},"end":{"line":338,"column":null}},"line":333},"14":{"name":"(anonymous_14)","decl":{"start":{"line":334,"column":11},"end":{"line":334,"column":18}},"loc":{"start":{"line":334,"column":61},"end":{"line":337,"column":null}},"line":334},"15":{"name":"(anonymous_15)","decl":{"start":{"line":343,"column":2},"end":{"line":343,"column":30}},"loc":{"start":{"line":343,"column":30},"end":{"line":356,"column":null}},"line":343},"16":{"name":"(anonymous_16)","decl":{"start":{"line":361,"column":2},"end":{"line":361,"column":21}},"loc":{"start":{"line":361,"column":21},"end":{"line":365,"column":null}},"line":361},"17":{"name":"(anonymous_17)","decl":{"start":{"line":370,"column":2},"end":{"line":370,"column":26}},"loc":{"start":{"line":370,"column":26},"end":{"line":372,"column":null}},"line":370},"18":{"name":"(anonymous_18)","decl":{"start":{"line":377,"column":8},"end":{"line":377,"column":34}},"loc":{"start":{"line":377,"column":34},"end":{"line":381,"column":null}},"line":377},"19":{"name":"createLocalEmbeddings","decl":{"start":{"line":387,"column":16},"end":{"line":387,"column":null}},"loc":{"start":{"line":389,"column":26},"end":{"line":391,"column":null}},"line":389},"20":{"name":"createEmbeddingGenerator","decl":{"start":{"line":410,"column":22},"end":{"line":410,"column":null}},"loc":{"start":{"line":412,"column":31},"end":{"line":416,"column":null}},"line":412}},"branchMap":{"0":{"loc":{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},"type":"if","locations":[{"start":{"line":105,"column":4},"end":{"line":112,"column":null}},{"start":{},"end":{}}],"line":105},"1":{"loc":{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},"type":"if","locations":[{"start":{"line":108,"column":6},"end":{"line":110,"column":null}},{"start":{},"end":{}}],"line":108},"2":{"loc":{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},"type":"if","locations":[{"start":{"line":117,"column":4},"end":{"line":120,"column":null}},{"start":{},"end":{}}],"line":117},"3":{"loc":{"start":{"line":123,"column":11},"end":{"line":123,"column":75}},"type":"binary-expr","locations":[{"start":{"line":123,"column":11},"end":{"line":123,"column":46}},{"start":{"line":123,"column":46},"end":{"line":123,"column":75}}],"line":123},"4":{"loc":{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},"type":"if","locations":[{"start":{"line":125,"column":6},"end":{"line":127,"column":null}},{"start":{},"end":{}}],"line":125},"5":{"loc":{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},"type":"if","locations":[{"start":{"line":169,"column":2},"end":{"line":173,"column":null}},{"start":{},"end":{}}],"line":169},"6":{"loc":{"start":{"line":196,"column":14},"end":{"line":196,"column":50}},"type":"default-arg","locations":[{"start":{"line":196,"column":46},"end":{"line":196,"column":50}}],"line":196},"7":{"loc":{"start":{"line":198,"column":16},"end":{"line":198,"column":null}},"type":"binary-expr","locations":[{"start":{"line":198,"column":16},"end":{"line":198,"column":35}},{"start":{"line":198,"column":35},"end":{"line":198,"column":null}}],"line":198},"8":{"loc":{"start":{"line":200,"column":15},"end":{"line":200,"column":null}},"type":"binary-expr","locations":[{"start":{"line":200,"column":15},"end":{"line":200,"column":33}},{"start":{"line":200,"column":33},"end":{"line":200,"column":null}}],"line":200},"9":{"loc":{"start":{"line":201,"column":18},"end":{"line":201,"column":null}},"type":"binary-expr","locations":[{"start":{"line":201,"column":18},"end":{"line":201,"column":39}},{"start":{"line":201,"column":39},"end":{"line":201,"column":null}}],"line":201},"10":{"loc":{"start":{"line":202,"column":20},"end":{"line":202,"column":null}},"type":"binary-expr","locations":[{"start":{"line":202,"column":20},"end":{"line":202,"column":43}},{"start":{"line":202,"column":43},"end":{"line":202,"column":null}}],"line":202},"11":{"loc":{"start":{"line":203,"column":20},"end":{"line":203,"column":null}},"type":"binary-expr","locations":[{"start":{"line":203,"column":20},"end":{"line":203,"column":43}},{"start":{"line":203,"column":43},"end":{"line":203,"column":null}}],"line":203},"12":{"loc":{"start":{"line":204,"column":20},"end":{"line":204,"column":null}},"type":"binary-expr","locations":[{"start":{"line":204,"column":20},"end":{"line":204,"column":43}},{"start":{"line":204,"column":43},"end":{"line":204,"column":null}}],"line":204},"13":{"loc":{"start":{"line":205,"column":16},"end":{"line":205,"column":null}},"type":"binary-expr","locations":[{"start":{"line":205,"column":16},"end":{"line":205,"column":35}},{"start":{"line":205,"column":35},"end":{"line":205,"column":null}}],"line":205},"14":{"loc":{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},"type":"if","locations":[{"start":{"line":208,"column":4},"end":{"line":210,"column":null}},{"start":{},"end":{}}],"line":208},"15":{"loc":{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},"type":"if","locations":[{"start":{"line":217,"column":4},"end":{"line":219,"column":null}},{"start":{},"end":{}}],"line":217},"16":{"loc":{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},"type":"if","locations":[{"start":{"line":221,"column":4},"end":{"line":223,"column":null}},{"start":{},"end":{}}],"line":221},"17":{"loc":{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},"type":"if","locations":[{"start":{"line":225,"column":4},"end":{"line":228,"column":null}},{"start":{},"end":{}}],"line":225},"18":{"loc":{"start":{"line":239,"column":31},"end":{"line":249,"column":null}},"type":"cond-expr","locations":[{"start":{"line":240,"column":10},"end":{"line":248,"column":null}},{"start":{"line":249,"column":10},"end":{"line":249,"column":null}}],"line":239},"19":{"loc":{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},"type":"if","locations":[{"start":{"line":241,"column":12},"end":{"line":247,"column":null}},{"start":{"line":245,"column":12},"end":{"line":247,"column":null}}],"line":241},"20":{"loc":{"start":{"line":241,"column":16},"end":{"line":241,"column":83}},"type":"binary-expr","locations":[{"start":{"line":241,"column":16},"end":{"line":241,"column":50}},{"start":{"line":241,"column":50},"end":{"line":241,"column":83}}],"line":241},"21":{"loc":{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},"type":"if","locations":[{"start":{"line":245,"column":12},"end":{"line":247,"column":null}},{"start":{},"end":{}}],"line":245},"22":{"loc":{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},"type":"if","locations":[{"start":{"line":271,"column":4},"end":{"line":282,"column":null}},{"start":{},"end":{}}],"line":271},"23":{"loc":{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},"type":"if","locations":[{"start":{"line":273,"column":6},"end":{"line":280,"column":null}},{"start":{},"end":{}}],"line":273},"24":{"loc":{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},"type":"if","locations":[{"start":{"line":287,"column":4},"end":{"line":302,"column":null}},{"start":{"line":289,"column":11},"end":{"line":302,"column":null}}],"line":287},"25":{"loc":{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":292,"column":6},"end":{"line":301,"column":null}},{"start":{"line":295,"column":13},"end":{"line":301,"column":null}}],"line":292},"26":{"loc":{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},"type":"if","locations":[{"start":{"line":311,"column":4},"end":{"line":313,"column":null}},{"start":{},"end":{}}],"line":311},"27":{"loc":{"start":{"line":349,"column":8},"end":{"line":351,"column":null}},"type":"cond-expr","locations":[{"start":{"line":350,"column":12},"end":{"line":350,"column":null}},{"start":{"line":351,"column":12},"end":{"line":351,"column":null}}],"line":349},"28":{"loc":{"start":{"line":353,"column":19},"end":{"line":353,"column":null}},"type":"binary-expr","locations":[{"start":{"line":353,"column":19},"end":{"line":353,"column":45}},{"start":{"line":353,"column":45},"end":{"line":353,"column":null}}],"line":353},"29":{"loc":{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},"type":"if","locations":[{"start":{"line":362,"column":4},"end":{"line":364,"column":null}},{"start":{},"end":{}}],"line":362}},"s":{"0":3,"1":3,"2":3,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":0,"32":0,"33":0,"34":0,"35":0,"36":0,"37":0,"38":0,"39":0,"40":0,"41":0,"42":0,"43":3,"44":3,"45":3,"46":3,"47":3,"48":3,"49":3,"50":3,"51":0,"52":3,"53":0,"54":3,"55":0,"56":0,"57":3,"58":3,"59":3,"60":3,"61":3,"62":0,"63":0,"64":0,"65":0,"66":3,"67":0,"68":0,"69":0,"70":0,"71":0,"72":0,"73":0,"74":0,"75":0,"76":0,"77":0,"78":0,"79":0,"80":0,"81":0,"82":0,"83":0,"84":0,"85":0,"86":0,"87":0,"88":0,"89":0,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":0,"102":0,"103":0,"104":0},"f":{"0":3,"1":0,"2":0,"3":0,"4":0,"5":0,"6":3,"7":3,"8":3,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0},"b":{"0":[0,0],"1":[0,0],"2":[0,0],"3":[0,0],"4":[0,0],"5":[0,0],"6":[3],"7":[3,3],"8":[3,3],"9":[3,3],"10":[3,3],"11":[3,3],"12":[3,3],"13":[3,0],"14":[3,0],"15":[0,3],"16":[0,3],"17":[0,3],"18":[0,3],"19":[0,0],"20":[0,0],"21":[0,0],"22":[0,0],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[0,0],"29":[0,0]},"meta":{"lastBranch":30,"lastFunction":21,"lastStatement":105,"seen":{"s:95:18:95:Infinity":0,"s:96:34:96:Infinity":1,"f:99:2:99:14":0,"s:100:4:100:Infinity":2,"f:103:2:103:6":1,"s:104:18:104:Infinity":3,"b:105:4:112:Infinity:undefined:undefined:undefined:undefined":0,"s:105:4:112:Infinity":4,"s:107:20:107:Infinity":5,"b:108:6:110:Infinity:undefined:undefined:undefined:undefined":1,"s:108:6:110:Infinity":6,"s:109:8:109:Infinity":7,"s:111:6:111:Infinity":8,"s:113:4:113:Infinity":9,"f:116:2:116:6":2,"b:117:4:120:Infinity:undefined:undefined:undefined:undefined":2,"s:117:4:120:Infinity":10,"s:118:6:118:Infinity":11,"s:119:6:119:Infinity":12,"s:123:4:128:Infinity":13,"b:123:11:123:46:123:46:123:75":3,"s:124:21:124:Infinity":14,"b:125:6:127:Infinity:undefined:undefined:undefined:undefined":4,"s:125:6:127:Infinity":15,"s:126:8:126:Infinity":16,"s:130:4:130:Infinity":17,"s:131:4:131:Infinity":18,"f:134:2:134:16":3,"s:135:4:135:Infinity":19,"s:136:4:136:Infinity":20,"f:139:6:139:21":4,"s:140:4:140:Infinity":21,"f:147:9:147:29":5,"s:148:20:148:Infinity":22,"s:150:13:150:Infinity":23,"s:151:2:154:Infinity":24,"s:151:15:151:18":25,"s:152:4:152:Infinity":26,"s:153:4:153:Infinity":27,"s:156:2:161:Infinity":28,"s:156:15:156:18":29,"s:158:4:158:Infinity":30,"s:159:4:159:Infinity":31,"s:160:4:160:Infinity":32,"s:164:13:164:Infinity":33,"s:165:2:167:Infinity":34,"s:165:15:165:18":35,"s:166:4:166:Infinity":36,"s:168:2:168:Infinity":37,"b:169:2:173:Infinity:undefined:undefined:undefined:undefined":5,"s:169:2:173:Infinity":38,"s:170:4:172:Infinity":39,"s:170:17:170:20":40,"s:171:6:171:Infinity":41,"s:175:2:175:Infinity":42,"s:186:49:186:Infinity":43,"s:187:26:187:Infinity":44,"s:188:47:188:Infinity":45,"s:189:18:194:Infinity":46,"f:196:2:196:14":6,"b:196:46:196:50":6,"s:197:4:206:Infinity":47,"b:198:16:198:35:198:35:198:Infinity":7,"b:200:15:200:33:200:33:200:Infinity":8,"b:201:18:201:39:201:39:201:Infinity":9,"b:202:20:202:43:202:43:202:Infinity":10,"b:203:20:203:43:203:43:203:Infinity":11,"b:204:20:204:43:204:43:204:Infinity":12,"b:205:16:205:35:205:35:205:Infinity":13,"b:208:4:210:Infinity:undefined:undefined:undefined:undefined":14,"s:208:4:210:Infinity":48,"s:209:6:209:Infinity":49,"f:216:8:216:36":7,"b:217:4:219:Infinity:undefined:undefined:undefined:undefined":15,"s:217:4:219:Infinity":50,"s:218:6:218:Infinity":51,"b:221:4:223:Infinity:undefined:undefined:undefined:undefined":16,"s:221:4:223:Infinity":52,"s:222:6:222:Infinity":53,"b:225:4:228:Infinity:undefined:undefined:undefined:undefined":17,"s:225:4:228:Infinity":54,"s:226:6:226:Infinity":55,"s:227:6:227:Infinity":56,"s:230:4:230:Infinity":57,"s:231:4:231:Infinity":58,"f:234:16:234:43":8,"s:235:4:261:Infinity":59,"s:237:27:237:Infinity":60,"s:239:31:249:Infinity":61,"b:240:10:248:Infinity:249:10:249:Infinity":18,"f:240:10:240:11":9,"b:241:12:247:Infinity:245:12:247:Infinity":19,"s:241:12:247:Infinity":62,"b:241:16:241:50:241:50:241:83":20,"s:242:14:244:Infinity":63,"b:245:12:247:Infinity:undefined:undefined:undefined:undefined":21,"s:245:12:247:Infinity":64,"s:246:14:246:Infinity":65,"s:251:6:253:Infinity":66,"s:256:6:259:Infinity":67,"s:260:6:260:Infinity":68,"f:267:8:267:14":10,"s:268:22:268:Infinity":69,"b:271:4:282:Infinity:undefined:undefined:undefined:undefined":22,"s:271:4:282:Infinity":70,"s:272:21:272:Infinity":71,"b:273:6:280:Infinity:undefined:undefined:undefined:undefined":23,"s:273:6:280:Infinity":72,"s:274:8:274:Infinity":73,"s:275:8:279:Infinity":74,"s:281:6:281:Infinity":75,"b:287:4:302:Infinity:289:11:302:Infinity":24,"s:287:4:302:Infinity":76,"s:288:6:288:Infinity":77,"s:290:6:290:Infinity":78,"b:292:6:301:Infinity:295:13:301:Infinity":25,"s:292:6:301:Infinity":79,"s:294:8:294:Infinity":80,"s:296:23:299:Infinity":81,"s:300:8:300:Infinity":82,"s:304:19:304:Infinity":83,"s:307:4:307:Infinity":84,"s:308:4:308:Infinity":85,"b:311:4:313:Infinity:undefined:undefined:undefined:undefined":26,"s:311:4:313:Infinity":86,"s:312:6:312:Infinity":87,"s:315:4:319:Infinity":88,"f:325:8:325:19":11,"s:327:4:327:Infinity":89,"f:327:33:327:34":12,"s:327:43:327:59":90,"f:333:2:333:37":13,"s:334:4:337:Infinity":91,"f:334:11:334:18":14,"s:335:21:335:Infinity":92,"s:336:6:336:Infinity":93,"f:343:2:343:30":15,"s:344:4:355:Infinity":94,"b:350:12:350:Infinity:351:12:351:Infinity":27,"b:353:19:353:45:353:45:353:Infinity":28,"f:361:2:361:21":16,"b:362:4:364:Infinity:undefined:undefined:undefined:undefined":29,"s:362:4:364:Infinity":95,"s:363:6:363:Infinity":96,"f:370:2:370:26":17,"s:371:4:371:Infinity":97,"f:377:8:377:34":18,"s:378:4:378:Infinity":98,"s:379:4:379:Infinity":99,"s:380:4:380:Infinity":100,"f:387:16:387:Infinity":19,"s:390:2:390:Infinity":101,"f:410:22:410:Infinity":20,"s:413:18:413:Infinity":102,"s:414:2:414:Infinity":103,"s:415:2:415:Infinity":104}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/ai-enrichment.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/ai-enrichment.ts","statementMap":{"0":{"start":{"line":30,"column":30},"end":{"line":30,"column":null}},"1":{"start":{"line":33,"column":36},"end":{"line":33,"column":null}},"2":{"start":{"line":36,"column":111},"end":{"line":36,"column":null}},"3":{"start":{"line":45,"column":16},"end":{"line":45,"column":null}},"4":{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},"5":{"start":{"line":46,"column":14},"end":{"line":46,"column":null}},"6":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"7":{"start":{"line":56,"column":21},"end":{"line":56,"column":null}},"8":{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},"9":{"start":{"line":57,"column":28},"end":{"line":57,"column":null}},"10":{"start":{"line":60,"column":2},"end":{"line":60,"column":null}},"11":{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},"12":{"start":{"line":71,"column":4},"end":{"line":71,"column":null}},"13":{"start":{"line":74,"column":2},"end":{"line":90,"column":null}},"14":{"start":{"line":75,"column":10},"end":{"line":86,"column":null}},"15":{"start":{"line":87,"column":4},"end":{"line":87,"column":null}},"16":{"start":{"line":89,"column":4},"end":{"line":89,"column":null}},"17":{"start":{"line":99,"column":21},"end":{"line":99,"column":null}},"18":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},"19":{"start":{"line":100,"column":28},"end":{"line":100,"column":null}},"20":{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},"21":{"start":{"line":102,"column":30},"end":{"line":102,"column":null}},"22":{"start":{"line":104,"column":2},"end":{"line":115,"column":null}},"23":{"start":{"line":105,"column":4},"end":{"line":109,"column":null}},"24":{"start":{"line":110,"column":4},"end":{"line":110,"column":null}},"25":{"start":{"line":111,"column":4},"end":{"line":111,"column":null}},"26":{"start":{"line":113,"column":4},"end":{"line":113,"column":null}},"27":{"start":{"line":114,"column":4},"end":{"line":114,"column":null}},"28":{"start":{"line":126,"column":2},"end":{"line":130,"column":null}},"29":{"start":{"line":145,"column":2},"end":{"line":178,"column":null}},"30":{"start":{"line":147,"column":18},"end":{"line":147,"column":null}},"31":{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},"32":{"start":{"line":149,"column":6},"end":{"line":149,"column":null}},"33":{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},"34":{"start":{"line":151,"column":6},"end":{"line":151,"column":null}},"35":{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},"36":{"start":{"line":154,"column":6},"end":{"line":154,"column":null}},"37":{"start":{"line":156,"column":4},"end":{"line":156,"column":null}},"38":{"start":{"line":158,"column":19},"end":{"line":158,"column":null}},"39":{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},"40":{"start":{"line":167,"column":6},"end":{"line":167,"column":null}},"41":{"start":{"line":170,"column":4},"end":{"line":175,"column":null}},"42":{"start":{"line":173,"column":58},"end":{"line":173,"column":85}},"43":{"start":{"line":174,"column":64},"end":{"line":174,"column":90}},"44":{"start":{"line":177,"column":4},"end":{"line":177,"column":null}},"45":{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},"46":{"start":{"line":194,"column":31},"end":{"line":194,"column":null}},"47":{"start":{"line":196,"column":2},"end":{"line":205,"column":null}},"48":{"start":{"line":197,"column":19},"end":{"line":197,"column":null}},"49":{"start":{"line":198,"column":25},"end":{"line":198,"column":null}},"50":{"start":{"line":200,"column":23},"end":{"line":200,"column":null}},"51":{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},"52":{"start":{"line":201,"column":21},"end":{"line":201,"column":null}},"53":{"start":{"line":202,"column":4},"end":{"line":202,"column":null}},"54":{"start":{"line":204,"column":4},"end":{"line":204,"column":null}},"55":{"start":{"line":212,"column":2},"end":{"line":212,"column":null}},"56":{"start":{"line":219,"column":2},"end":{"line":219,"column":null}},"57":{"start":{"line":220,"column":2},"end":{"line":220,"column":null}},"58":{"start":{"line":227,"column":2},"end":{"line":227,"column":null}},"59":{"start":{"line":238,"column":2},"end":{"line":238,"column":null}},"60":{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},"61":{"start":{"line":240,"column":4},"end":{"line":240,"column":null}},"62":{"start":{"line":261,"column":2},"end":{"line":267,"column":null}},"63":{"start":{"line":280,"column":2},"end":{"line":309,"column":null}},"64":{"start":{"line":281,"column":18},"end":{"line":281,"column":null}},"65":{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},"66":{"start":{"line":282,"column":39},"end":{"line":282,"column":null}},"67":{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},"68":{"start":{"line":283,"column":40},"end":{"line":283,"column":null}},"69":{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},"70":{"start":{"line":284,"column":33},"end":{"line":284,"column":null}},"71":{"start":{"line":285,"column":4},"end":{"line":285,"column":null}},"72":{"start":{"line":287,"column":19},"end":{"line":287,"column":null}},"73":{"start":{"line":290,"column":22},"end":{"line":290,"column":null}},"74":{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},"75":{"start":{"line":291,"column":20},"end":{"line":291,"column":null}},"76":{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},"77":{"start":{"line":296,"column":6},"end":{"line":296,"column":null}},"78":{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},"79":{"start":{"line":298,"column":6},"end":{"line":298,"column":null}},"80":{"start":{"line":298,"column":55},"end":{"line":298,"column":64}},"81":{"start":{"line":300,"column":6},"end":{"line":300,"column":null}},"82":{"start":{"line":303,"column":4},"end":{"line":306,"column":null}},"83":{"start":{"line":308,"column":4},"end":{"line":308,"column":null}},"84":{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},"85":{"start":{"line":324,"column":31},"end":{"line":324,"column":null}},"86":{"start":{"line":326,"column":2},"end":{"line":335,"column":null}},"87":{"start":{"line":327,"column":19},"end":{"line":327,"column":null}},"88":{"start":{"line":328,"column":25},"end":{"line":328,"column":null}},"89":{"start":{"line":330,"column":23},"end":{"line":330,"column":null}},"90":{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},"91":{"start":{"line":331,"column":21},"end":{"line":331,"column":null}},"92":{"start":{"line":332,"column":4},"end":{"line":332,"column":null}},"93":{"start":{"line":334,"column":4},"end":{"line":334,"column":null}}},"fnMap":{"0":{"name":"isEnvEnabled","decl":{"start":{"line":44,"column":9},"end":{"line":44,"column":40}},"loc":{"start":{"line":44,"column":40},"end":{"line":48,"column":null}},"line":44},"1":{"name":"isAIEnrichmentEnabled","decl":{"start":{"line":55,"column":16},"end":{"line":55,"column":49}},"loc":{"start":{"line":55,"column":49},"end":{"line":61,"column":null}},"line":55},"2":{"name":"runClaudePrint","decl":{"start":{"line":68,"column":9},"end":{"line":68,"column":24}},"loc":{"start":{"line":68,"column":96},"end":{"line":91,"column":null}},"line":68},"3":{"name":"isClaudeCliAvailable","decl":{"start":{"line":97,"column":9},"end":{"line":97,"column":41}},"loc":{"start":{"line":97,"column":41},"end":{"line":116,"column":null}},"line":97},"4":{"name":"buildExtractionPrompt","decl":{"start":{"line":121,"column":16},"end":{"line":121,"column":null}},"loc":{"start":{"line":125,"column":10},"end":{"line":139,"column":null}},"line":125},"5":{"name":"parseAIResponse","decl":{"start":{"line":144,"column":16},"end":{"line":144,"column":32}},"loc":{"start":{"line":144,"column":74},"end":{"line":179,"column":null}},"line":144},"6":{"name":"(anonymous_6)","decl":{"start":{"line":173,"column":42},"end":{"line":173,"column":43}},"loc":{"start":{"line":173,"column":58},"end":{"line":173,"column":85}},"line":173},"7":{"name":"(anonymous_7)","decl":{"start":{"line":174,"column":48},"end":{"line":174,"column":49}},"loc":{"start":{"line":174,"column":64},"end":{"line":174,"column":90}},"line":174},"8":{"name":"enrichWithAI","decl":{"start":{"line":188,"column":22},"end":{"line":188,"column":null}},"loc":{"start":{"line":193,"column":39},"end":{"line":206,"column":null}},"line":193},"9":{"name":"isAIEnrichmentAvailable","decl":{"start":{"line":211,"column":22},"end":{"line":211,"column":66}},"loc":{"start":{"line":211,"column":66},"end":{"line":213,"column":null}},"line":211},"10":{"name":"resetAIEnrichmentCache","decl":{"start":{"line":218,"column":16},"end":{"line":218,"column":47}},"loc":{"start":{"line":218,"column":47},"end":{"line":221,"column":null}},"line":218},"11":{"name":"_setCliAvailableForTesting","decl":{"start":{"line":226,"column":16},"end":{"line":226,"column":43}},"loc":{"start":{"line":226,"column":69},"end":{"line":228,"column":null}},"line":226},"12":{"name":"_setRunClaudePrintMockForTesting","decl":{"start":{"line":235,"column":16},"end":{"line":235,"column":null}},"loc":{"start":{"line":237,"column":8},"end":{"line":242,"column":null}},"line":237},"13":{"name":"buildSummaryPrompt","decl":{"start":{"line":257,"column":16},"end":{"line":257,"column":null}},"loc":{"start":{"line":260,"column":10},"end":{"line":274,"column":null}},"line":260},"14":{"name":"parseSummaryResponse","decl":{"start":{"line":279,"column":16},"end":{"line":279,"column":37}},"loc":{"start":{"line":279,"column":75},"end":{"line":310,"column":null}},"line":279},"15":{"name":"(anonymous_15)","decl":{"start":{"line":298,"column":39},"end":{"line":298,"column":40}},"loc":{"start":{"line":298,"column":55},"end":{"line":298,"column":64}},"line":298},"16":{"name":"enrichSummaryWithAI","decl":{"start":{"line":319,"column":22},"end":{"line":319,"column":null}},"loc":{"start":{"line":323,"column":35},"end":{"line":336,"column":null}},"line":323}},"branchMap":{"0":{"loc":{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},"type":"if","locations":[{"start":{"line":46,"column":2},"end":{"line":46,"column":null}},{"start":{},"end":{}}],"line":46},"1":{"loc":{"start":{"line":47,"column":9},"end":{"line":47,"column":null}},"type":"binary-expr","locations":[{"start":{"line":47,"column":9},"end":{"line":47,"column":29}},{"start":{"line":47,"column":29},"end":{"line":47,"column":null}}],"line":47},"2":{"loc":{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":2},"end":{"line":57,"column":null}},{"start":{},"end":{}}],"line":57},"3":{"loc":{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},"type":"if","locations":[{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},{"start":{},"end":{}}],"line":70},"4":{"loc":{"start":{"line":87,"column":11},"end":{"line":87,"column":null}},"type":"binary-expr","locations":[{"start":{"line":87,"column":11},"end":{"line":87,"column":28}},{"start":{"line":87,"column":28},"end":{"line":87,"column":null}}],"line":87},"5":{"loc":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},"type":"if","locations":[{"start":{"line":100,"column":2},"end":{"line":100,"column":null}},{"start":{},"end":{}}],"line":100},"6":{"loc":{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},"type":"if","locations":[{"start":{"line":102,"column":2},"end":{"line":102,"column":null}},{"start":{},"end":{}}],"line":102},"7":{"loc":{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},"type":"if","locations":[{"start":{"line":148,"column":4},"end":{"line":152,"column":null}},{"start":{"line":150,"column":4},"end":{"line":152,"column":null}}],"line":148},"8":{"loc":{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},"type":"if","locations":[{"start":{"line":150,"column":4},"end":{"line":152,"column":null}},{"start":{},"end":{}}],"line":150},"9":{"loc":{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},"type":"if","locations":[{"start":{"line":153,"column":4},"end":{"line":155,"column":null}},{"start":{},"end":{}}],"line":153},"10":{"loc":{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},"type":"if","locations":[{"start":{"line":161,"column":4},"end":{"line":168,"column":null}},{"start":{},"end":{}}],"line":161},"11":{"loc":{"start":{"line":162,"column":6},"end":{"line":165,"column":null}},"type":"binary-expr","locations":[{"start":{"line":162,"column":6},"end":{"line":162,"column":null}},{"start":{"line":163,"column":6},"end":{"line":163,"column":null}},{"start":{"line":164,"column":6},"end":{"line":164,"column":null}},{"start":{"line":165,"column":6},"end":{"line":165,"column":null}}],"line":162},"12":{"loc":{"start":{"line":192,"column":2},"end":{"line":192,"column":null}},"type":"default-arg","locations":[{"start":{"line":192,"column":22},"end":{"line":192,"column":null}}],"line":192},"13":{"loc":{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},"type":"if","locations":[{"start":{"line":194,"column":2},"end":{"line":194,"column":null}},{"start":{},"end":{}}],"line":194},"14":{"loc":{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},"type":"if","locations":[{"start":{"line":201,"column":4},"end":{"line":201,"column":null}},{"start":{},"end":{}}],"line":201},"15":{"loc":{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},"type":"if","locations":[{"start":{"line":239,"column":2},"end":{"line":241,"column":null}},{"start":{},"end":{}}],"line":239},"16":{"loc":{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},"type":"if","locations":[{"start":{"line":282,"column":4},"end":{"line":283,"column":null}},{"start":{"line":282,"column":64},"end":{"line":283,"column":null}}],"line":282},"17":{"loc":{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},"type":"if","locations":[{"start":{"line":282,"column":64},"end":{"line":283,"column":null}},{"start":{},"end":{}}],"line":282},"18":{"loc":{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},"type":"if","locations":[{"start":{"line":284,"column":4},"end":{"line":284,"column":null}},{"start":{},"end":{}}],"line":284},"19":{"loc":{"start":{"line":290,"column":22},"end":{"line":290,"column":null}},"type":"cond-expr","locations":[{"start":{"line":290,"column":61},"end":{"line":290,"column":80}},{"start":{"line":290,"column":80},"end":{"line":290,"column":null}}],"line":290},"20":{"loc":{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},"type":"if","locations":[{"start":{"line":291,"column":4},"end":{"line":291,"column":null}},{"start":{},"end":{}}],"line":291},"21":{"loc":{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":295,"column":4},"end":{"line":301,"column":null}},{"start":{"line":297,"column":4},"end":{"line":301,"column":null}}],"line":295},"22":{"loc":{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},"type":"if","locations":[{"start":{"line":297,"column":4},"end":{"line":301,"column":null}},{"start":{"line":299,"column":11},"end":{"line":301,"column":null}}],"line":297},"23":{"loc":{"start":{"line":322,"column":2},"end":{"line":322,"column":null}},"type":"default-arg","locations":[{"start":{"line":322,"column":22},"end":{"line":322,"column":null}}],"line":322},"24":{"loc":{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},"type":"if","locations":[{"start":{"line":324,"column":2},"end":{"line":324,"column":null}},{"start":{},"end":{}}],"line":324},"25":{"loc":{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},"type":"if","locations":[{"start":{"line":331,"column":4},"end":{"line":331,"column":null}},{"start":{},"end":{}}],"line":331}},"s":{"0":5,"1":5,"2":5,"3":43,"4":43,"5":25,"6":18,"7":11,"8":11,"9":8,"10":3,"11":24,"12":18,"13":6,"14":6,"15":6,"16":6,"17":32,"18":32,"19":5,"20":27,"21":20,"22":7,"23":7,"24":7,"25":7,"26":0,"27":0,"28":24,"29":27,"30":27,"31":27,"32":2,"33":25,"34":1,"35":27,"36":3,"37":27,"38":27,"39":27,"40":5,"41":18,"42":20,"43":20,"44":4,"45":24,"46":4,"47":20,"48":20,"49":20,"50":20,"51":20,"52":8,"53":9,"54":3,"55":3,"56":193,"57":193,"58":2,"59":87,"60":87,"61":20,"62":6,"63":12,"64":12,"65":12,"66":1,"67":11,"68":0,"69":12,"70":1,"71":12,"72":12,"73":12,"74":12,"75":1,"76":8,"77":6,"78":2,"79":1,"80":2,"81":1,"82":8,"83":3,"84":5,"85":1,"86":4,"87":4,"88":4,"89":4,"90":4,"91":0,"92":4,"93":0},"f":{"0":43,"1":11,"2":24,"3":32,"4":24,"5":27,"6":20,"7":20,"8":24,"9":3,"10":193,"11":2,"12":87,"13":6,"14":12,"15":2,"16":5},"b":{"0":[25,18],"1":[18,15],"2":[8,3],"3":[18,6],"4":[6,0],"5":[5,27],"6":[20,7],"7":[2,25],"8":[1,24],"9":[3,24],"10":[5,22],"11":[27,22,20,19],"12":[24],"13":[4,20],"14":[8,12],"15":[20,67],"16":[1,11],"17":[0,11],"18":[1,11],"19":[8,1],"20":[1,11],"21":[6,2],"22":[1,1],"23":[5],"24":[1,4],"25":[0,4]},"meta":{"lastBranch":26,"lastFunction":17,"lastStatement":94,"seen":{"s:30:30:30:Infinity":0,"s:33:36:33:Infinity":1,"s:36:111:36:Infinity":2,"f:44:9:44:40":0,"s:45:16:45:Infinity":3,"b:46:2:46:Infinity:undefined:undefined:undefined:undefined":0,"s:46:2:46:Infinity":4,"s:46:14:46:Infinity":5,"s:47:2:47:Infinity":6,"b:47:9:47:29:47:29:47:Infinity":1,"f:55:16:55:49":1,"s:56:21:56:Infinity":7,"b:57:2:57:Infinity:undefined:undefined:undefined:undefined":2,"s:57:2:57:Infinity":8,"s:57:28:57:Infinity":9,"s:60:2:60:Infinity":10,"f:68:9:68:24":2,"b:70:2:72:Infinity:undefined:undefined:undefined:undefined":3,"s:70:2:72:Infinity":11,"s:71:4:71:Infinity":12,"s:74:2:90:Infinity":13,"s:75:10:86:Infinity":14,"s:87:4:87:Infinity":15,"b:87:11:87:28:87:28:87:Infinity":4,"s:89:4:89:Infinity":16,"f:97:9:97:41":3,"s:99:21:99:Infinity":17,"b:100:2:100:Infinity:undefined:undefined:undefined:undefined":5,"s:100:2:100:Infinity":18,"s:100:28:100:Infinity":19,"b:102:2:102:Infinity:undefined:undefined:undefined:undefined":6,"s:102:2:102:Infinity":20,"s:102:30:102:Infinity":21,"s:104:2:115:Infinity":22,"s:105:4:109:Infinity":23,"s:110:4:110:Infinity":24,"s:111:4:111:Infinity":25,"s:113:4:113:Infinity":26,"s:114:4:114:Infinity":27,"f:121:16:121:Infinity":4,"s:126:2:130:Infinity":28,"f:144:16:144:32":5,"s:145:2:178:Infinity":29,"s:147:18:147:Infinity":30,"b:148:4:152:Infinity:150:4:152:Infinity":7,"s:148:4:152:Infinity":31,"s:149:6:149:Infinity":32,"b:150:4:152:Infinity:undefined:undefined:undefined:undefined":8,"s:150:4:152:Infinity":33,"s:151:6:151:Infinity":34,"b:153:4:155:Infinity:undefined:undefined:undefined:undefined":9,"s:153:4:155:Infinity":35,"s:154:6:154:Infinity":36,"s:156:4:156:Infinity":37,"s:158:19:158:Infinity":38,"b:161:4:168:Infinity:undefined:undefined:undefined:undefined":10,"s:161:4:168:Infinity":39,"b:162:6:162:Infinity:163:6:163:Infinity:164:6:164:Infinity:165:6:165:Infinity":11,"s:167:6:167:Infinity":40,"s:170:4:175:Infinity":41,"f:173:42:173:43":6,"s:173:58:173:85":42,"f:174:48:174:49":7,"s:174:64:174:90":43,"s:177:4:177:Infinity":44,"f:188:22:188:Infinity":8,"b:192:22:192:Infinity":12,"b:194:2:194:Infinity:undefined:undefined:undefined:undefined":13,"s:194:2:194:Infinity":45,"s:194:31:194:Infinity":46,"s:196:2:205:Infinity":47,"s:197:19:197:Infinity":48,"s:198:25:198:Infinity":49,"s:200:23:200:Infinity":50,"b:201:4:201:Infinity:undefined:undefined:undefined:undefined":14,"s:201:4:201:Infinity":51,"s:201:21:201:Infinity":52,"s:202:4:202:Infinity":53,"s:204:4:204:Infinity":54,"f:211:22:211:66":9,"s:212:2:212:Infinity":55,"f:218:16:218:47":10,"s:219:2:219:Infinity":56,"s:220:2:220:Infinity":57,"f:226:16:226:43":11,"s:227:2:227:Infinity":58,"f:235:16:235:Infinity":12,"s:238:2:238:Infinity":59,"b:239:2:241:Infinity:undefined:undefined:undefined:undefined":15,"s:239:2:241:Infinity":60,"s:240:4:240:Infinity":61,"f:257:16:257:Infinity":13,"s:261:2:267:Infinity":62,"f:279:16:279:37":14,"s:280:2:309:Infinity":63,"s:281:18:281:Infinity":64,"b:282:4:283:Infinity:282:64:283:Infinity":16,"s:282:4:283:Infinity":65,"s:282:39:282:Infinity":66,"b:282:64:283:Infinity:undefined:undefined:undefined:undefined":17,"s:282:64:283:Infinity":67,"s:283:40:283:Infinity":68,"b:284:4:284:Infinity:undefined:undefined:undefined:undefined":18,"s:284:4:284:Infinity":69,"s:284:33:284:Infinity":70,"s:285:4:285:Infinity":71,"s:287:19:287:Infinity":72,"s:290:22:290:Infinity":73,"b:290:61:290:80:290:80:290:Infinity":19,"b:291:4:291:Infinity:undefined:undefined:undefined:undefined":20,"s:291:4:291:Infinity":74,"s:291:20:291:Infinity":75,"b:295:4:301:Infinity:297:4:301:Infinity":21,"s:295:4:301:Infinity":76,"s:296:6:296:Infinity":77,"b:297:4:301:Infinity:299:11:301:Infinity":22,"s:297:4:301:Infinity":78,"s:298:6:298:Infinity":79,"f:298:39:298:40":15,"s:298:55:298:64":80,"s:300:6:300:Infinity":81,"s:303:4:306:Infinity":82,"s:308:4:308:Infinity":83,"f:319:22:319:Infinity":16,"b:322:22:322:Infinity":23,"b:324:2:324:Infinity:undefined:undefined:undefined:undefined":24,"s:324:2:324:Infinity":84,"s:324:31:324:Infinity":85,"s:326:2:335:Infinity":86,"s:327:19:327:Infinity":87,"s:328:25:328:Infinity":88,"s:330:23:330:Infinity":89,"b:331:4:331:Infinity:undefined:undefined:undefined:undefined":25,"s:331:4:331:Infinity":90,"s:331:21:331:Infinity":91,"s:332:4:332:Infinity":92,"s:334:4:334:Infinity":93}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/context.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/context.ts","statementMap":{"0":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"1":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"2":{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},"3":{"start":{"line":38,"column":6},"end":{"line":38,"column":null}},"4":{"start":{"line":46,"column":4},"end":{"line":78,"column":null}},"5":{"start":{"line":48,"column":6},"end":{"line":48,"column":null}},"6":{"start":{"line":51,"column":22},"end":{"line":51,"column":null}},"7":{"start":{"line":52,"column":25},"end":{"line":52,"column":null}},"8":{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},"9":{"start":{"line":56,"column":8},"end":{"line":60,"column":null}},"10":{"start":{"line":64,"column":6},"end":{"line":68,"column":null}},"11":{"start":{"line":71,"column":6},"end":{"line":71,"column":null}},"12":{"start":{"line":73,"column":6},"end":{"line":77,"column":null}},"13":{"start":{"line":86,"column":4},"end":{"line":86,"column":null}},"14":{"start":{"line":116,"column":18},"end":{"line":116,"column":null}},"15":{"start":{"line":117,"column":2},"end":{"line":117,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":28,"column":2},"end":{"line":28,"column":14}},"loc":{"start":{"line":28,"column":63},"end":{"line":31,"column":null}},"line":28},"1":{"name":"(anonymous_1)","decl":{"start":{"line":36,"column":8},"end":{"line":36,"column":34}},"loc":{"start":{"line":36,"column":34},"end":{"line":40,"column":null}},"line":36},"2":{"name":"(anonymous_2)","decl":{"start":{"line":45,"column":8},"end":{"line":45,"column":16}},"loc":{"start":{"line":45,"column":65},"end":{"line":79,"column":null}},"line":45},"3":{"name":"(anonymous_3)","decl":{"start":{"line":85,"column":10},"end":{"line":85,"column":34}},"loc":{"start":{"line":85,"column":59},"end":{"line":109,"column":null}},"line":85},"4":{"name":"createContextHook","decl":{"start":{"line":115,"column":16},"end":{"line":115,"column":34}},"loc":{"start":{"line":115,"column":60},"end":{"line":118,"column":null}},"line":115}},"branchMap":{"0":{"loc":{"start":{"line":28,"column":42},"end":{"line":28,"column":63}},"type":"default-arg","locations":[{"start":{"line":28,"column":56},"end":{"line":28,"column":63}}],"line":28},"1":{"loc":{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},"type":"if","locations":[{"start":{"line":37,"column":4},"end":{"line":39,"column":null}},{"start":{},"end":{}}],"line":37},"2":{"loc":{"start":{"line":52,"column":25},"end":{"line":52,"column":null}},"type":"binary-expr","locations":[{"start":{"line":52,"column":25},"end":{"line":52,"column":45}},{"start":{"line":52,"column":45},"end":{"line":52,"column":null}}],"line":52},"3":{"loc":{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":61,"column":null}},{"start":{},"end":{}}],"line":54},"4":{"loc":{"start":{"line":76,"column":15},"end":{"line":76,"column":null}},"type":"cond-expr","locations":[{"start":{"line":76,"column":40},"end":{"line":76,"column":56}},{"start":{"line":76,"column":56},"end":{"line":76,"column":null}}],"line":76}},"s":{"0":5,"1":5,"2":5,"3":5,"4":5,"5":5,"6":4,"7":4,"8":5,"9":2,"10":2,"11":1,"12":1,"13":2,"14":4,"15":4},"f":{"0":5,"1":5,"2":5,"3":2,"4":4},"b":{"0":[5],"1":[5,0],"2":[4,4],"3":[2,3],"4":[1,0]},"meta":{"lastBranch":5,"lastFunction":5,"lastStatement":16,"seen":{"f:28:2:28:14":0,"b:28:56:28:63":0,"s:29:4:29:Infinity":0,"s:30:4:30:Infinity":1,"f:36:8:36:34":1,"b:37:4:39:Infinity:undefined:undefined:undefined:undefined":1,"s:37:4:39:Infinity":2,"s:38:6:38:Infinity":3,"f:45:8:45:16":2,"s:46:4:78:Infinity":4,"s:48:6:48:Infinity":5,"s:51:22:51:Infinity":6,"s:52:25:52:Infinity":7,"b:52:25:52:45:52:45:52:Infinity":2,"b:54:6:61:Infinity:undefined:undefined:undefined:undefined":3,"s:54:6:61:Infinity":8,"s:56:8:60:Infinity":9,"s:64:6:68:Infinity":10,"s:71:6:71:Infinity":11,"s:73:6:77:Infinity":12,"b:76:40:76:56:76:56:76:Infinity":4,"f:85:10:85:34":3,"s:86:4:86:Infinity":13,"f:115:16:115:34":4,"s:116:18:116:Infinity":14,"s:117:2:117:Infinity":15}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/observation.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/observation.ts","statementMap":{"0":{"start":{"line":21,"column":19},"end":{"line":38,"column":null}},"1":{"start":{"line":51,"column":4},"end":{"line":51,"column":null}},"2":{"start":{"line":52,"column":4},"end":{"line":52,"column":null}},"3":{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},"4":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"5":{"start":{"line":68,"column":4},"end":{"line":127,"column":null}},"6":{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},"7":{"start":{"line":71,"column":8},"end":{"line":74,"column":null}},"8":{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},"9":{"start":{"line":79,"column":8},"end":{"line":82,"column":null}},"10":{"start":{"line":86,"column":23},"end":{"line":86,"column":null}},"11":{"start":{"line":87,"column":26},"end":{"line":87,"column":null}},"12":{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},"13":{"start":{"line":89,"column":8},"end":{"line":92,"column":null}},"14":{"start":{"line":96,"column":6},"end":{"line":96,"column":null}},"15":{"start":{"line":99,"column":6},"end":{"line":99,"column":null}},"16":{"start":{"line":102,"column":18},"end":{"line":109,"column":null}},"17":{"start":{"line":114,"column":6},"end":{"line":117,"column":null}},"18":{"start":{"line":120,"column":6},"end":{"line":120,"column":null}},"19":{"start":{"line":122,"column":6},"end":{"line":126,"column":null}},"20":{"start":{"line":135,"column":18},"end":{"line":135,"column":null}},"21":{"start":{"line":136,"column":2},"end":{"line":136,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":50,"column":2},"end":{"line":50,"column":14}},"loc":{"start":{"line":50,"column":63},"end":{"line":53,"column":null}},"line":50},"1":{"name":"(anonymous_1)","decl":{"start":{"line":58,"column":8},"end":{"line":58,"column":34}},"loc":{"start":{"line":58,"column":34},"end":{"line":62,"column":null}},"line":58},"2":{"name":"(anonymous_2)","decl":{"start":{"line":67,"column":8},"end":{"line":67,"column":16}},"loc":{"start":{"line":67,"column":65},"end":{"line":128,"column":null}},"line":67},"3":{"name":"createObservationHook","decl":{"start":{"line":134,"column":16},"end":{"line":134,"column":38}},"loc":{"start":{"line":134,"column":68},"end":{"line":137,"column":null}},"line":134}},"branchMap":{"0":{"loc":{"start":{"line":50,"column":42},"end":{"line":50,"column":63}},"type":"default-arg","locations":[{"start":{"line":50,"column":56},"end":{"line":50,"column":63}}],"line":50},"1":{"loc":{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},"type":"if","locations":[{"start":{"line":59,"column":4},"end":{"line":61,"column":null}},{"start":{},"end":{}}],"line":59},"2":{"loc":{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},"type":"if","locations":[{"start":{"line":70,"column":6},"end":{"line":75,"column":null}},{"start":{},"end":{}}],"line":70},"3":{"loc":{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},"type":"if","locations":[{"start":{"line":78,"column":6},"end":{"line":83,"column":null}},{"start":{},"end":{}}],"line":78},"4":{"loc":{"start":{"line":86,"column":38},"end":{"line":86,"column":59}},"type":"binary-expr","locations":[{"start":{"line":86,"column":38},"end":{"line":86,"column":57}},{"start":{"line":86,"column":57},"end":{"line":86,"column":59}}],"line":86},"5":{"loc":{"start":{"line":87,"column":41},"end":{"line":87,"column":65}},"type":"binary-expr","locations":[{"start":{"line":87,"column":41},"end":{"line":87,"column":63}},{"start":{"line":87,"column":63},"end":{"line":87,"column":65}}],"line":87},"6":{"loc":{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},"type":"if","locations":[{"start":{"line":88,"column":6},"end":{"line":93,"column":null}},{"start":{},"end":{}}],"line":88},"7":{"loc":{"start":{"line":88,"column":10},"end":{"line":88,"column":53}},"type":"binary-expr","locations":[{"start":{"line":88,"column":10},"end":{"line":88,"column":31}},{"start":{"line":88,"column":31},"end":{"line":88,"column":53}}],"line":88},"8":{"loc":{"start":{"line":125,"column":15},"end":{"line":125,"column":null}},"type":"cond-expr","locations":[{"start":{"line":125,"column":40},"end":{"line":125,"column":56}},{"start":{"line":125,"column":56},"end":{"line":125,"column":null}}],"line":125}},"s":{"0":2,"1":17,"2":17,"3":31,"4":31,"5":73,"6":73,"7":1,"8":72,"9":4,"10":68,"11":73,"12":73,"13":0,"14":68,"15":67,"16":67,"17":67,"18":1,"19":1,"20":16,"21":16},"f":{"0":17,"1":31,"2":73,"3":16},"b":{"0":[17],"1":[31,0],"2":[1,72],"3":[4,68],"4":[68,0],"5":[73,0],"6":[0,73],"7":[73,0],"8":[1,0]},"meta":{"lastBranch":9,"lastFunction":4,"lastStatement":22,"seen":{"s:21:19:38:Infinity":0,"f:50:2:50:14":0,"b:50:56:50:63":0,"s:51:4:51:Infinity":1,"s:52:4:52:Infinity":2,"f:58:8:58:34":1,"b:59:4:61:Infinity:undefined:undefined:undefined:undefined":1,"s:59:4:61:Infinity":3,"s:60:6:60:Infinity":4,"f:67:8:67:16":2,"s:68:4:127:Infinity":5,"b:70:6:75:Infinity:undefined:undefined:undefined:undefined":2,"s:70:6:75:Infinity":6,"s:71:8:74:Infinity":7,"b:78:6:83:Infinity:undefined:undefined:undefined:undefined":3,"s:78:6:83:Infinity":8,"s:79:8:82:Infinity":9,"s:86:23:86:Infinity":10,"b:86:38:86:57:86:57:86:59":4,"s:87:26:87:Infinity":11,"b:87:41:87:63:87:63:87:65":5,"b:88:6:93:Infinity:undefined:undefined:undefined:undefined":6,"s:88:6:93:Infinity":12,"b:88:10:88:31:88:31:88:53":7,"s:89:8:92:Infinity":13,"s:96:6:96:Infinity":14,"s:99:6:99:Infinity":15,"s:102:18:109:Infinity":16,"s:114:6:117:Infinity":17,"s:120:6:120:Infinity":18,"s:122:6:126:Infinity":19,"b:125:40:125:56:125:56:125:Infinity":8,"f:134:16:134:38":3,"s:135:18:135:Infinity":20,"s:136:2:136:Infinity":21}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/service.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/service.ts","statementMap":{"0":{"start":{"line":53,"column":48},"end":{"line":59,"column":null}},"1":{"start":{"line":69,"column":38},"end":{"line":69,"column":null}},"2":{"start":{"line":70,"column":33},"end":{"line":70,"column":null}},"3":{"start":{"line":415,"column":47},"end":{"line":415,"column":null}},"4":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"5":{"start":{"line":75,"column":4},"end":{"line":75,"column":null}},"6":{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},"7":{"start":{"line":82,"column":26},"end":{"line":82,"column":null}},"8":{"start":{"line":85,"column":16},"end":{"line":85,"column":null}},"9":{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},"10":{"start":{"line":87,"column":6},"end":{"line":87,"column":null}},"11":{"start":{"line":91,"column":4},"end":{"line":91,"column":null}},"12":{"start":{"line":94,"column":4},"end":{"line":94,"column":null}},"13":{"start":{"line":96,"column":4},"end":{"line":96,"column":null}},"14":{"start":{"line":99,"column":4},"end":{"line":99,"column":null}},"15":{"start":{"line":101,"column":4},"end":{"line":101,"column":null}},"16":{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},"17":{"start":{"line":108,"column":39},"end":{"line":108,"column":null}},"18":{"start":{"line":110,"column":4},"end":{"line":110,"column":null}},"19":{"start":{"line":111,"column":4},"end":{"line":111,"column":null}},"20":{"start":{"line":112,"column":4},"end":{"line":112,"column":null}},"21":{"start":{"line":121,"column":4},"end":{"line":121,"column":null}},"22":{"start":{"line":124,"column":21},"end":{"line":124,"column":null}},"23":{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},"24":{"start":{"line":126,"column":6},"end":{"line":126,"column":null}},"25":{"start":{"line":130,"column":16},"end":{"line":130,"column":null}},"26":{"start":{"line":131,"column":19},"end":{"line":134,"column":null}},"27":{"start":{"line":136,"column":4},"end":{"line":144,"column":null}},"28":{"start":{"line":153,"column":4},"end":{"line":153,"column":null}},"29":{"start":{"line":156,"column":4},"end":{"line":156,"column":null}},"30":{"start":{"line":159,"column":25},"end":{"line":159,"column":null}},"31":{"start":{"line":160,"column":16},"end":{"line":160,"column":null}},"32":{"start":{"line":162,"column":19},"end":{"line":165,"column":null}},"33":{"start":{"line":167,"column":15},"end":{"line":167,"column":null}},"34":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"35":{"start":{"line":171,"column":6},"end":{"line":171,"column":null}},"36":{"start":{"line":174,"column":4},"end":{"line":180,"column":null}},"37":{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},"38":{"start":{"line":187,"column":18},"end":{"line":187,"column":null}},"39":{"start":{"line":189,"column":16},"end":{"line":191,"column":null}},"40":{"start":{"line":193,"column":4},"end":{"line":193,"column":null}},"41":{"start":{"line":200,"column":4},"end":{"line":200,"column":null}},"42":{"start":{"line":202,"column":17},"end":{"line":206,"column":null}},"43":{"start":{"line":208,"column":4},"end":{"line":214,"column":null}},"44":{"start":{"line":208,"column":28},"end":{"line":214,"column":6}},"45":{"start":{"line":221,"column":4},"end":{"line":221,"column":null}},"46":{"start":{"line":223,"column":17},"end":{"line":229,"column":null}},"47":{"start":{"line":231,"column":4},"end":{"line":237,"column":null}},"48":{"start":{"line":231,"column":28},"end":{"line":237,"column":6}},"49":{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},"50":{"start":{"line":244,"column":18},"end":{"line":244,"column":null}},"51":{"start":{"line":246,"column":16},"end":{"line":246,"column":null}},"52":{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},"53":{"start":{"line":249,"column":6},"end":{"line":249,"column":null}},"54":{"start":{"line":252,"column":4},"end":{"line":252,"column":null}},"55":{"start":{"line":259,"column":4},"end":{"line":259,"column":null}},"56":{"start":{"line":261,"column":16},"end":{"line":261,"column":null}},"57":{"start":{"line":262,"column":4},"end":{"line":266,"column":null}},"58":{"start":{"line":273,"column":4},"end":{"line":273,"column":null}},"59":{"start":{"line":275,"column":17},"end":{"line":280,"column":null}},"60":{"start":{"line":282,"column":4},"end":{"line":282,"column":null}},"61":{"start":{"line":282,"column":27},"end":{"line":282,"column":49}},"62":{"start":{"line":298,"column":4},"end":{"line":298,"column":null}},"63":{"start":{"line":300,"column":10},"end":{"line":300,"column":null}},"64":{"start":{"line":301,"column":16},"end":{"line":301,"column":null}},"65":{"start":{"line":302,"column":10},"end":{"line":302,"column":null}},"66":{"start":{"line":303,"column":10},"end":{"line":303,"column":null}},"67":{"start":{"line":304,"column":25},"end":{"line":304,"column":null}},"68":{"start":{"line":305,"column":37},"end":{"line":305,"column":null}},"69":{"start":{"line":308,"column":21},"end":{"line":308,"column":null}},"70":{"start":{"line":309,"column":10},"end":{"line":312,"column":null}},"71":{"start":{"line":316,"column":10},"end":{"line":316,"column":null}},"72":{"start":{"line":317,"column":10},"end":{"line":317,"column":null}},"73":{"start":{"line":318,"column":10},"end":{"line":318,"column":null}},"74":{"start":{"line":319,"column":10},"end":{"line":319,"column":null}},"75":{"start":{"line":321,"column":4},"end":{"line":324,"column":null}},"76":{"start":{"line":327,"column":4},"end":{"line":327,"column":null}},"77":{"start":{"line":328,"column":4},"end":{"line":328,"column":null}},"78":{"start":{"line":331,"column":4},"end":{"line":335,"column":null}},"79":{"start":{"line":337,"column":4},"end":{"line":355,"column":null}},"80":{"start":{"line":364,"column":4},"end":{"line":364,"column":null}},"81":{"start":{"line":366,"column":16},"end":{"line":368,"column":null}},"82":{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},"83":{"start":{"line":370,"column":14},"end":{"line":370,"column":null}},"84":{"start":{"line":372,"column":21},"end":{"line":372,"column":null}},"85":{"start":{"line":372,"column":102},"end":{"line":372,"column":106}},"86":{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},"87":{"start":{"line":373,"column":19},"end":{"line":373,"column":null}},"88":{"start":{"line":375,"column":4},"end":{"line":385,"column":null}},"89":{"start":{"line":387,"column":4},"end":{"line":387,"column":null}},"90":{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},"91":{"start":{"line":398,"column":20},"end":{"line":398,"column":null}},"92":{"start":{"line":399,"column":6},"end":{"line":402,"column":null}},"93":{"start":{"line":400,"column":25},"end":{"line":400,"column":null}},"94":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"95":{"start":{"line":401,"column":33},"end":{"line":401,"column":null}},"96":{"start":{"line":403,"column":6},"end":{"line":403,"column":null}},"97":{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},"98":{"start":{"line":405,"column":6},"end":{"line":405,"column":null}},"99":{"start":{"line":407,"column":20},"end":{"line":407,"column":null}},"100":{"start":{"line":408,"column":6},"end":{"line":408,"column":null}},"101":{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},"102":{"start":{"line":426,"column":18},"end":{"line":426,"column":null}},"103":{"start":{"line":427,"column":4},"end":{"line":429,"column":null}},"104":{"start":{"line":439,"column":21},"end":{"line":439,"column":null}},"105":{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},"106":{"start":{"line":443,"column":6},"end":{"line":458,"column":null}},"107":{"start":{"line":444,"column":20},"end":{"line":444,"column":null}},"108":{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},"109":{"start":{"line":446,"column":10},"end":{"line":452,"column":null}},"110":{"start":{"line":447,"column":12},"end":{"line":447,"column":null}},"111":{"start":{"line":448,"column":12},"end":{"line":448,"column":null}},"112":{"start":{"line":451,"column":12},"end":{"line":451,"column":null}},"113":{"start":{"line":451,"column":18},"end":{"line":451,"column":40}},"114":{"start":{"line":454,"column":10},"end":{"line":454,"column":null}},"115":{"start":{"line":454,"column":16},"end":{"line":454,"column":38}},"116":{"start":{"line":457,"column":8},"end":{"line":457,"column":null}},"117":{"start":{"line":457,"column":14},"end":{"line":457,"column":36}},"118":{"start":{"line":463,"column":4},"end":{"line":468,"column":null}},"119":{"start":{"line":464,"column":6},"end":{"line":464,"column":null}},"120":{"start":{"line":467,"column":6},"end":{"line":467,"column":null}},"121":{"start":{"line":471,"column":4},"end":{"line":476,"column":null}},"122":{"start":{"line":472,"column":6},"end":{"line":472,"column":null}},"123":{"start":{"line":473,"column":6},"end":{"line":473,"column":null}},"124":{"start":{"line":475,"column":6},"end":{"line":475,"column":null}},"125":{"start":{"line":475,"column":12},"end":{"line":475,"column":27}},"126":{"start":{"line":479,"column":4},"end":{"line":490,"column":null}},"127":{"start":{"line":480,"column":22},"end":{"line":480,"column":null}},"128":{"start":{"line":481,"column":12},"end":{"line":485,"column":null}},"129":{"start":{"line":486,"column":6},"end":{"line":486,"column":null}},"130":{"start":{"line":489,"column":6},"end":{"line":489,"column":null}},"131":{"start":{"line":489,"column":12},"end":{"line":489,"column":34}},"132":{"start":{"line":499,"column":4},"end":{"line":499,"column":null}},"133":{"start":{"line":501,"column":21},"end":{"line":501,"column":null}},"134":{"start":{"line":502,"column":4},"end":{"line":502,"column":null}},"135":{"start":{"line":504,"column":16},"end":{"line":504,"column":null}},"136":{"start":{"line":505,"column":4},"end":{"line":596,"column":null}},"137":{"start":{"line":506,"column":41},"end":{"line":506,"column":null}},"138":{"start":{"line":507,"column":23},"end":{"line":507,"column":null}},"139":{"start":{"line":508,"column":25},"end":{"line":508,"column":null}},"140":{"start":{"line":509,"column":6},"end":{"line":509,"column":null}},"141":{"start":{"line":511,"column":47},"end":{"line":515,"column":null}},"142":{"start":{"line":518,"column":29},"end":{"line":526,"column":null}},"143":{"start":{"line":519,"column":21},"end":{"line":521,"column":null}},"144":{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},"145":{"start":{"line":523,"column":10},"end":{"line":523,"column":null}},"146":{"start":{"line":525,"column":8},"end":{"line":525,"column":null}},"147":{"start":{"line":529,"column":6},"end":{"line":566,"column":null}},"148":{"start":{"line":530,"column":21},"end":{"line":530,"column":null}},"149":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"150":{"start":{"line":532,"column":19},"end":{"line":532,"column":null}},"151":{"start":{"line":534,"column":22},"end":{"line":534,"column":null}},"152":{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},"153":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"154":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"155":{"start":{"line":540,"column":8},"end":{"line":565,"column":null}},"156":{"start":{"line":541,"column":22},"end":{"line":543,"column":null}},"157":{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},"158":{"start":{"line":546,"column":12},"end":{"line":546,"column":null}},"159":{"start":{"line":547,"column":12},"end":{"line":547,"column":null}},"160":{"start":{"line":550,"column":23},"end":{"line":552,"column":null}},"161":{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},"162":{"start":{"line":554,"column":12},"end":{"line":554,"column":null}},"163":{"start":{"line":555,"column":12},"end":{"line":555,"column":null}},"164":{"start":{"line":558,"column":25},"end":{"line":558,"column":null}},"165":{"start":{"line":559,"column":25},"end":{"line":559,"column":null}},"166":{"start":{"line":560,"column":10},"end":{"line":560,"column":null}},"167":{"start":{"line":561,"column":10},"end":{"line":561,"column":null}},"168":{"start":{"line":562,"column":10},"end":{"line":562,"column":null}},"169":{"start":{"line":564,"column":10},"end":{"line":564,"column":null}},"170":{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},"171":{"start":{"line":570,"column":8},"end":{"line":592,"column":null}},"172":{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},"173":{"start":{"line":571,"column":61},"end":{"line":571,"column":null}},"174":{"start":{"line":572,"column":10},"end":{"line":591,"column":null}},"175":{"start":{"line":573,"column":30},"end":{"line":573,"column":null}},"176":{"start":{"line":574,"column":25},"end":{"line":576,"column":null}},"177":{"start":{"line":578,"column":12},"end":{"line":590,"column":null}},"178":{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},"179":{"start":{"line":579,"column":65},"end":{"line":579,"column":null}},"180":{"start":{"line":580,"column":27},"end":{"line":582,"column":null}},"181":{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},"182":{"start":{"line":583,"column":25},"end":{"line":583,"column":null}},"183":{"start":{"line":584,"column":14},"end":{"line":589,"column":null}},"184":{"start":{"line":585,"column":31},"end":{"line":585,"column":null}},"185":{"start":{"line":586,"column":31},"end":{"line":586,"column":null}},"186":{"start":{"line":587,"column":16},"end":{"line":587,"column":null}},"187":{"start":{"line":588,"column":16},"end":{"line":588,"column":null}},"188":{"start":{"line":595,"column":6},"end":{"line":595,"column":null}},"189":{"start":{"line":595,"column":12},"end":{"line":595,"column":34}},"190":{"start":{"line":598,"column":4},"end":{"line":598,"column":null}},"191":{"start":{"line":607,"column":4},"end":{"line":607,"column":null}},"192":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"193":{"start":{"line":610,"column":4},"end":{"line":610,"column":null}},"194":{"start":{"line":612,"column":16},"end":{"line":612,"column":null}},"195":{"start":{"line":613,"column":4},"end":{"line":645,"column":null}},"196":{"start":{"line":615,"column":30},"end":{"line":623,"column":null}},"197":{"start":{"line":616,"column":21},"end":{"line":618,"column":null}},"198":{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},"199":{"start":{"line":620,"column":10},"end":{"line":620,"column":null}},"200":{"start":{"line":622,"column":8},"end":{"line":622,"column":null}},"201":{"start":{"line":625,"column":6},"end":{"line":642,"column":null}},"202":{"start":{"line":626,"column":21},"end":{"line":626,"column":null}},"203":{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},"204":{"start":{"line":628,"column":19},"end":{"line":628,"column":null}},"205":{"start":{"line":630,"column":8},"end":{"line":641,"column":null}},"206":{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},"207":{"start":{"line":632,"column":12},"end":{"line":632,"column":null}},"208":{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},"209":{"start":{"line":637,"column":10},"end":{"line":637,"column":null}},"210":{"start":{"line":638,"column":10},"end":{"line":638,"column":null}},"211":{"start":{"line":640,"column":10},"end":{"line":640,"column":null}},"212":{"start":{"line":644,"column":6},"end":{"line":644,"column":null}},"213":{"start":{"line":644,"column":12},"end":{"line":644,"column":34}},"214":{"start":{"line":647,"column":4},"end":{"line":647,"column":null}},"215":{"start":{"line":654,"column":4},"end":{"line":654,"column":null}},"216":{"start":{"line":656,"column":17},"end":{"line":661,"column":null}},"217":{"start":{"line":663,"column":4},"end":{"line":663,"column":null}},"218":{"start":{"line":663,"column":27},"end":{"line":663,"column":53}},"219":{"start":{"line":670,"column":4},"end":{"line":670,"column":null}},"220":{"start":{"line":672,"column":17},"end":{"line":677,"column":null}},"221":{"start":{"line":679,"column":4},"end":{"line":679,"column":null}},"222":{"start":{"line":679,"column":27},"end":{"line":679,"column":53}},"223":{"start":{"line":688,"column":4},"end":{"line":688,"column":null}},"224":{"start":{"line":690,"column":31},"end":{"line":693,"column":null}},"225":{"start":{"line":695,"column":29},"end":{"line":698,"column":null}},"226":{"start":{"line":700,"column":24},"end":{"line":700,"column":null}},"227":{"start":{"line":701,"column":29},"end":{"line":701,"column":null}},"228":{"start":{"line":704,"column":21},"end":{"line":706,"column":null}},"229":{"start":{"line":708,"column":4},"end":{"line":714,"column":null}},"230":{"start":{"line":727,"column":28},"end":{"line":727,"column":null}},"231":{"start":{"line":729,"column":4},"end":{"line":729,"column":null}},"232":{"start":{"line":730,"column":4},"end":{"line":730,"column":null}},"233":{"start":{"line":733,"column":4},"end":{"line":733,"column":null}},"234":{"start":{"line":734,"column":4},"end":{"line":734,"column":null}},"235":{"start":{"line":735,"column":4},"end":{"line":735,"column":null}},"236":{"start":{"line":736,"column":4},"end":{"line":736,"column":null}},"237":{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},"238":{"start":{"line":740,"column":6},"end":{"line":740,"column":null}},"239":{"start":{"line":741,"column":6},"end":{"line":741,"column":null}},"240":{"start":{"line":743,"column":6},"end":{"line":759,"column":null}},"241":{"start":{"line":744,"column":21},"end":{"line":744,"column":null}},"242":{"start":{"line":745,"column":8},"end":{"line":745,"column":null}},"243":{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},"244":{"start":{"line":747,"column":10},"end":{"line":747,"column":null}},"245":{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},"246":{"start":{"line":750,"column":10},"end":{"line":750,"column":null}},"247":{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},"248":{"start":{"line":753,"column":10},"end":{"line":753,"column":null}},"249":{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},"250":{"start":{"line":756,"column":10},"end":{"line":756,"column":null}},"251":{"start":{"line":758,"column":8},"end":{"line":758,"column":null}},"252":{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},"253":{"start":{"line":764,"column":6},"end":{"line":764,"column":null}},"254":{"start":{"line":765,"column":6},"end":{"line":765,"column":null}},"255":{"start":{"line":767,"column":6},"end":{"line":770,"column":null}},"256":{"start":{"line":768,"column":21},"end":{"line":768,"column":null}},"257":{"start":{"line":769,"column":8},"end":{"line":769,"column":null}},"258":{"start":{"line":771,"column":6},"end":{"line":771,"column":null}},"259":{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},"260":{"start":{"line":776,"column":6},"end":{"line":776,"column":null}},"261":{"start":{"line":777,"column":6},"end":{"line":777,"column":null}},"262":{"start":{"line":779,"column":6},"end":{"line":790,"column":null}},"263":{"start":{"line":780,"column":21},"end":{"line":780,"column":null}},"264":{"start":{"line":781,"column":21},"end":{"line":781,"column":null}},"265":{"start":{"line":782,"column":23},"end":{"line":782,"column":null}},"266":{"start":{"line":783,"column":8},"end":{"line":783,"column":null}},"267":{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},"268":{"start":{"line":785,"column":10},"end":{"line":785,"column":null}},"269":{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},"270":{"start":{"line":788,"column":10},"end":{"line":788,"column":null}},"271":{"start":{"line":791,"column":6},"end":{"line":791,"column":null}},"272":{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},"273":{"start":{"line":796,"column":6},"end":{"line":796,"column":null}},"274":{"start":{"line":797,"column":6},"end":{"line":797,"column":null}},"275":{"start":{"line":799,"column":6},"end":{"line":814,"column":null}},"276":{"start":{"line":800,"column":21},"end":{"line":800,"column":null}},"277":{"start":{"line":801,"column":23},"end":{"line":801,"column":null}},"278":{"start":{"line":802,"column":8},"end":{"line":802,"column":null}},"279":{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},"280":{"start":{"line":805,"column":10},"end":{"line":805,"column":null}},"281":{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},"282":{"start":{"line":809,"column":10},"end":{"line":809,"column":null}},"283":{"start":{"line":812,"column":8},"end":{"line":812,"column":null}},"284":{"start":{"line":813,"column":8},"end":{"line":813,"column":null}},"285":{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},"286":{"start":{"line":819,"column":6},"end":{"line":819,"column":null}},"287":{"start":{"line":820,"column":6},"end":{"line":820,"column":null}},"288":{"start":{"line":824,"column":21},"end":{"line":824,"column":null}},"289":{"start":{"line":825,"column":26},"end":{"line":825,"column":null}},"290":{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},"291":{"start":{"line":827,"column":35},"end":{"line":827,"column":null}},"292":{"start":{"line":828,"column":28},"end":{"line":828,"column":null}},"293":{"start":{"line":829,"column":6},"end":{"line":829,"column":null}},"294":{"start":{"line":830,"column":6},"end":{"line":830,"column":null}},"295":{"start":{"line":831,"column":6},"end":{"line":831,"column":null}},"296":{"start":{"line":834,"column":4},"end":{"line":834,"column":null}},"297":{"start":{"line":841,"column":23},"end":{"line":841,"column":null}},"298":{"start":{"line":843,"column":28},"end":{"line":843,"column":null}},"299":{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},"300":{"start":{"line":844,"column":28},"end":{"line":844,"column":null}},"301":{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},"302":{"start":{"line":845,"column":30},"end":{"line":845,"column":null}},"303":{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},"304":{"start":{"line":847,"column":6},"end":{"line":847,"column":null}},"305":{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},"306":{"start":{"line":849,"column":30},"end":{"line":849,"column":null}},"307":{"start":{"line":850,"column":4},"end":{"line":850,"column":null}},"308":{"start":{"line":857,"column":25},"end":{"line":857,"column":null}},"309":{"start":{"line":858,"column":20},"end":{"line":858,"column":null}},"310":{"start":{"line":859,"column":20},"end":{"line":859,"column":null}},"311":{"start":{"line":862,"column":35},"end":{"line":862,"column":null}},"312":{"start":{"line":863,"column":39},"end":{"line":863,"column":null}},"313":{"start":{"line":864,"column":31},"end":{"line":864,"column":null}},"314":{"start":{"line":866,"column":4},"end":{"line":881,"column":null}},"315":{"start":{"line":867,"column":6},"end":{"line":880,"column":null}},"316":{"start":{"line":868,"column":22},"end":{"line":868,"column":null}},"317":{"start":{"line":869,"column":25},"end":{"line":869,"column":null}},"318":{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},"319":{"start":{"line":872,"column":10},"end":{"line":872,"column":null}},"320":{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},"321":{"start":{"line":874,"column":10},"end":{"line":874,"column":null}},"322":{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},"323":{"start":{"line":876,"column":10},"end":{"line":876,"column":null}},"324":{"start":{"line":884,"column":20},"end":{"line":886,"column":null}},"325":{"start":{"line":885,"column":25},"end":{"line":885,"column":81}},"326":{"start":{"line":889,"column":43},"end":{"line":889,"column":null}},"327":{"start":{"line":890,"column":4},"end":{"line":892,"column":null}},"328":{"start":{"line":891,"column":6},"end":{"line":891,"column":null}},"329":{"start":{"line":893,"column":37},"end":{"line":893,"column":null}},"330":{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},"331":{"start":{"line":894,"column":22},"end":{"line":894,"column":null}},"332":{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},"333":{"start":{"line":895,"column":21},"end":{"line":895,"column":null}},"334":{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},"335":{"start":{"line":896,"column":24},"end":{"line":896,"column":null}},"336":{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},"337":{"start":{"line":897,"column":23},"end":{"line":897,"column":null}},"338":{"start":{"line":900,"column":18},"end":{"line":902,"column":null}},"339":{"start":{"line":904,"column":4},"end":{"line":914,"column":null}},"340":{"start":{"line":923,"column":4},"end":{"line":923,"column":null}},"341":{"start":{"line":925,"column":16},"end":{"line":925,"column":null}},"342":{"start":{"line":926,"column":19},"end":{"line":941,"column":null}},"343":{"start":{"line":943,"column":15},"end":{"line":943,"column":null}},"344":{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},"345":{"start":{"line":947,"column":6},"end":{"line":947,"column":null}},"346":{"start":{"line":950,"column":4},"end":{"line":954,"column":null}},"347":{"start":{"line":961,"column":4},"end":{"line":961,"column":null}},"348":{"start":{"line":963,"column":17},"end":{"line":968,"column":null}},"349":{"start":{"line":970,"column":4},"end":{"line":970,"column":null}},"350":{"start":{"line":970,"column":27},"end":{"line":970,"column":49}},"351":{"start":{"line":980,"column":4},"end":{"line":980,"column":null}},"352":{"start":{"line":983,"column":17},"end":{"line":985,"column":null}},"353":{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},"354":{"start":{"line":987,"column":27},"end":{"line":987,"column":null}},"355":{"start":{"line":989,"column":20},"end":{"line":989,"column":null}},"356":{"start":{"line":992,"column":24},"end":{"line":992,"column":null}},"357":{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},"358":{"start":{"line":993,"column":22},"end":{"line":993,"column":null}},"359":{"start":{"line":996,"column":25},"end":{"line":1001,"column":null}},"360":{"start":{"line":1004,"column":21},"end":{"line":1004,"column":null}},"361":{"start":{"line":1004,"column":86},"end":{"line":1004,"column":90}},"362":{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},"363":{"start":{"line":1005,"column":19},"end":{"line":1005,"column":null}},"364":{"start":{"line":1008,"column":4},"end":{"line":1012,"column":null}},"365":{"start":{"line":1014,"column":4},"end":{"line":1014,"column":null}},"366":{"start":{"line":1018,"column":4},"end":{"line":1030,"column":null}},"367":{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},"368":{"start":{"line":1037,"column":6},"end":{"line":1037,"column":null}},"369":{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},"370":{"start":{"line":1042,"column":18},"end":{"line":1042,"column":null}},"371":{"start":{"line":1044,"column":4},"end":{"line":1056,"column":null}},"372":{"start":{"line":1058,"column":4},"end":{"line":1080,"column":null}},"373":{"start":{"line":1083,"column":4},"end":{"line":1094,"column":null}},"374":{"start":{"line":1097,"column":4},"end":{"line":1113,"column":null}},"375":{"start":{"line":1117,"column":4},"end":{"line":1126,"column":null}},"376":{"start":{"line":1129,"column":4},"end":{"line":1129,"column":null}},"377":{"start":{"line":1130,"column":4},"end":{"line":1130,"column":null}},"378":{"start":{"line":1131,"column":4},"end":{"line":1131,"column":null}},"379":{"start":{"line":1132,"column":4},"end":{"line":1132,"column":null}},"380":{"start":{"line":1133,"column":4},"end":{"line":1133,"column":null}},"381":{"start":{"line":1134,"column":4},"end":{"line":1134,"column":null}},"382":{"start":{"line":1135,"column":4},"end":{"line":1135,"column":null}},"383":{"start":{"line":1136,"column":4},"end":{"line":1136,"column":null}},"384":{"start":{"line":1139,"column":4},"end":{"line":1139,"column":null}},"385":{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},"386":{"start":{"line":1146,"column":18},"end":{"line":1146,"column":null}},"387":{"start":{"line":1148,"column":4},"end":{"line":1177,"column":null}},"388":{"start":{"line":1149,"column":25},"end":{"line":1149,"column":null}},"389":{"start":{"line":1150,"column":26},"end":{"line":1150,"column":null}},"390":{"start":{"line":1150,"column":54},"end":{"line":1150,"column":60}},"391":{"start":{"line":1152,"column":50},"end":{"line":1160,"column":null}},"392":{"start":{"line":1162,"column":6},"end":{"line":1166,"column":null}},"393":{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},"394":{"start":{"line":1164,"column":10},"end":{"line":1164,"column":null}},"395":{"start":{"line":1169,"column":6},"end":{"line":1174,"column":null}},"396":{"start":{"line":1170,"column":21},"end":{"line":1170,"column":null}},"397":{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},"398":{"start":{"line":1171,"column":28},"end":{"line":1171,"column":50}},"399":{"start":{"line":1172,"column":10},"end":{"line":1172,"column":null}},"400":{"start":{"line":1181,"column":4},"end":{"line":1191,"column":null}},"401":{"start":{"line":1195,"column":4},"end":{"line":1213,"column":null}},"402":{"start":{"line":1217,"column":16},"end":{"line":1217,"column":null}},"403":{"start":{"line":1218,"column":17},"end":{"line":1218,"column":null}},"404":{"start":{"line":1220,"column":20},"end":{"line":1220,"column":null}},"405":{"start":{"line":1221,"column":18},"end":{"line":1221,"column":null}},"406":{"start":{"line":1222,"column":17},"end":{"line":1222,"column":null}},"407":{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},"408":{"start":{"line":1224,"column":21},"end":{"line":1224,"column":null}},"409":{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},"410":{"start":{"line":1225,"column":22},"end":{"line":1225,"column":null}},"411":{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},"412":{"start":{"line":1226,"column":20},"end":{"line":1226,"column":null}},"413":{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},"414":{"start":{"line":1227,"column":18},"end":{"line":1227,"column":null}},"415":{"start":{"line":1229,"column":4},"end":{"line":1229,"column":null}},"416":{"start":{"line":1233,"column":4},"end":{"line":1239,"column":null}},"417":{"start":{"line":1234,"column":19},"end":{"line":1234,"column":null}},"418":{"start":{"line":1235,"column":20},"end":{"line":1235,"column":null}},"419":{"start":{"line":1236,"column":22},"end":{"line":1236,"column":null}},"420":{"start":{"line":1237,"column":21},"end":{"line":1237,"column":null}},"421":{"start":{"line":1238,"column":15},"end":{"line":1238,"column":null}},"422":{"start":{"line":1247,"column":2},"end":{"line":1247,"column":null}},"423":{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},"424":{"start":{"line":1256,"column":54},"end":{"line":1256,"column":null}},"425":{"start":{"line":1258,"column":2},"end":{"line":1301,"column":null}},"426":{"start":{"line":1259,"column":10},"end":{"line":1259,"column":null}},"427":{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},"428":{"start":{"line":1260,"column":18},"end":{"line":1260,"column":null}},"429":{"start":{"line":1262,"column":18},"end":{"line":1262,"column":null}},"430":{"start":{"line":1265,"column":4},"end":{"line":1296,"column":null}},"431":{"start":{"line":1265,"column":17},"end":{"line":1265,"column":35}},"432":{"start":{"line":1266,"column":6},"end":{"line":1295,"column":null}},"433":{"start":{"line":1267,"column":21},"end":{"line":1267,"column":null}},"434":{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},"435":{"start":{"line":1268,"column":39},"end":{"line":1268,"column":null}},"436":{"start":{"line":1270,"column":27},"end":{"line":1270,"column":null}},"437":{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},"438":{"start":{"line":1271,"column":25},"end":{"line":1271,"column":null}},"439":{"start":{"line":1273,"column":19},"end":{"line":1273,"column":null}},"440":{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},"441":{"start":{"line":1275,"column":10},"end":{"line":1275,"column":null}},"442":{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},"443":{"start":{"line":1278,"column":10},"end":{"line":1281,"column":null}},"444":{"start":{"line":1279,"column":45},"end":{"line":1279,"column":62}},"445":{"start":{"line":1280,"column":42},"end":{"line":1280,"column":48}},"446":{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},"447":{"start":{"line":1284,"column":19},"end":{"line":1284,"column":null}},"448":{"start":{"line":1287,"column":8},"end":{"line":1287,"column":null}},"449":{"start":{"line":1288,"column":8},"end":{"line":1288,"column":null}},"450":{"start":{"line":1291,"column":8},"end":{"line":1291,"column":null}},"451":{"start":{"line":1294,"column":8},"end":{"line":1294,"column":null}},"452":{"start":{"line":1298,"column":4},"end":{"line":1298,"column":null}},"453":{"start":{"line":1300,"column":4},"end":{"line":1300,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":73,"column":2},"end":{"line":73,"column":14}},"loc":{"start":{"line":73,"column":74},"end":{"line":76,"column":null}},"line":73},"1":{"name":"(anonymous_1)","decl":{"start":{"line":81,"column":8},"end":{"line":81,"column":36}},"loc":{"start":{"line":81,"column":36},"end":{"line":102,"column":null}},"line":81},"2":{"name":"(anonymous_2)","decl":{"start":{"line":107,"column":8},"end":{"line":107,"column":34}},"loc":{"start":{"line":107,"column":34},"end":{"line":113,"column":null}},"line":107},"3":{"name":"(anonymous_3)","decl":{"start":{"line":120,"column":8},"end":{"line":120,"column":20}},"loc":{"start":{"line":120,"column":97},"end":{"line":145,"column":null}},"line":120},"4":{"name":"(anonymous_4)","decl":{"start":{"line":152,"column":8},"end":{"line":152,"column":23}},"loc":{"start":{"line":152,"column":100},"end":{"line":181,"column":null}},"line":152},"5":{"name":"(anonymous_5)","decl":{"start":{"line":186,"column":2},"end":{"line":186,"column":18}},"loc":{"start":{"line":186,"column":45},"end":{"line":194,"column":null}},"line":186},"6":{"name":"(anonymous_6)","decl":{"start":{"line":199,"column":8},"end":{"line":199,"column":26}},"loc":{"start":{"line":199,"column":68},"end":{"line":215,"column":null}},"line":199},"7":{"name":"(anonymous_7)","decl":{"start":{"line":208,"column":20},"end":{"line":208,"column":28}},"loc":{"start":{"line":208,"column":28},"end":{"line":214,"column":6}},"line":208},"8":{"name":"(anonymous_8)","decl":{"start":{"line":220,"column":8},"end":{"line":220,"column":25}},"loc":{"start":{"line":220,"column":85},"end":{"line":238,"column":null}},"line":220},"9":{"name":"(anonymous_9)","decl":{"start":{"line":231,"column":20},"end":{"line":231,"column":28}},"loc":{"start":{"line":231,"column":28},"end":{"line":237,"column":6}},"line":231},"10":{"name":"(anonymous_10)","decl":{"start":{"line":243,"column":2},"end":{"line":243,"column":13}},"loc":{"start":{"line":243,"column":54},"end":{"line":253,"column":null}},"line":243},"11":{"name":"(anonymous_11)","decl":{"start":{"line":258,"column":8},"end":{"line":258,"column":24}},"loc":{"start":{"line":258,"column":76},"end":{"line":267,"column":null}},"line":258},"12":{"name":"(anonymous_12)","decl":{"start":{"line":272,"column":8},"end":{"line":272,"column":26}},"loc":{"start":{"line":272,"column":88},"end":{"line":283,"column":null}},"line":272},"13":{"name":"(anonymous_13)","decl":{"start":{"line":282,"column":20},"end":{"line":282,"column":27}},"loc":{"start":{"line":282,"column":27},"end":{"line":282,"column":49}},"line":282},"14":{"name":"(anonymous_14)","decl":{"start":{"line":290,"column":8},"end":{"line":290,"column":null}},"loc":{"start":{"line":297,"column":26},"end":{"line":356,"column":null}},"line":297},"15":{"name":"(anonymous_15)","decl":{"start":{"line":363,"column":8},"end":{"line":363,"column":26}},"loc":{"start":{"line":363,"column":56},"end":{"line":388,"column":null}},"line":363},"16":{"name":"(anonymous_16)","decl":{"start":{"line":372,"column":96},"end":{"line":372,"column":102}},"loc":{"start":{"line":372,"column":102},"end":{"line":372,"column":106}},"line":372},"17":{"name":"(anonymous_17)","decl":{"start":{"line":393,"column":10},"end":{"line":393,"column":null}},"loc":{"start":{"line":396,"column":12},"end":{"line":410,"column":null}},"line":396},"18":{"name":"(anonymous_18)","decl":{"start":{"line":421,"column":2},"end":{"line":421,"column":null}},"loc":{"start":{"line":425,"column":10},"end":{"line":430,"column":null}},"line":425},"19":{"name":"(anonymous_19)","decl":{"start":{"line":438,"column":2},"end":{"line":438,"column":22}},"loc":{"start":{"line":438,"column":79},"end":{"line":491,"column":null}},"line":438},"20":{"name":"(anonymous_20)","decl":{"start":{"line":498,"column":8},"end":{"line":498,"column":49}},"loc":{"start":{"line":498,"column":49},"end":{"line":599,"column":null}},"line":498},"21":{"name":"(anonymous_21)","decl":{"start":{"line":518,"column":50},"end":{"line":518,"column":56}},"loc":{"start":{"line":518,"column":56},"end":{"line":526,"column":7}},"line":518},"22":{"name":"(anonymous_22)","decl":{"start":{"line":606,"column":8},"end":{"line":606,"column":50}},"loc":{"start":{"line":606,"column":50},"end":{"line":648,"column":null}},"line":606},"23":{"name":"(anonymous_23)","decl":{"start":{"line":615,"column":51},"end":{"line":615,"column":57}},"loc":{"start":{"line":615,"column":57},"end":{"line":623,"column":7}},"line":615},"24":{"name":"(anonymous_24)","decl":{"start":{"line":653,"column":8},"end":{"line":653,"column":31}},"loc":{"start":{"line":653,"column":94},"end":{"line":664,"column":null}},"line":653},"25":{"name":"(anonymous_25)","decl":{"start":{"line":663,"column":20},"end":{"line":663,"column":27}},"loc":{"start":{"line":663,"column":27},"end":{"line":663,"column":53}},"line":663},"26":{"name":"(anonymous_26)","decl":{"start":{"line":669,"column":8},"end":{"line":669,"column":30}},"loc":{"start":{"line":669,"column":91},"end":{"line":680,"column":null}},"line":669},"27":{"name":"(anonymous_27)","decl":{"start":{"line":679,"column":20},"end":{"line":679,"column":27}},"loc":{"start":{"line":679,"column":27},"end":{"line":679,"column":53}},"line":679},"28":{"name":"(anonymous_28)","decl":{"start":{"line":687,"column":8},"end":{"line":687,"column":19}},"loc":{"start":{"line":687,"column":60},"end":{"line":715,"column":null}},"line":687},"29":{"name":"(anonymous_29)","decl":{"start":{"line":720,"column":10},"end":{"line":720,"column":null}},"loc":{"start":{"line":726,"column":12},"end":{"line":835,"column":null}},"line":726},"30":{"name":"(anonymous_30)","decl":{"start":{"line":840,"column":8},"end":{"line":840,"column":24}},"loc":{"start":{"line":840,"column":60},"end":{"line":851,"column":null}},"line":840},"31":{"name":"(anonymous_31)","decl":{"start":{"line":856,"column":8},"end":{"line":856,"column":34}},"loc":{"start":{"line":856,"column":104},"end":{"line":915,"column":null}},"line":856},"32":{"name":"(anonymous_32)","decl":{"start":{"line":885,"column":20},"end":{"line":885,"column":25}},"loc":{"start":{"line":885,"column":25},"end":{"line":885,"column":81}},"line":885},"33":{"name":"(anonymous_33)","decl":{"start":{"line":922,"column":8},"end":{"line":922,"column":27}},"loc":{"start":{"line":922,"column":103},"end":{"line":955,"column":null}},"line":922},"34":{"name":"(anonymous_34)","decl":{"start":{"line":960,"column":8},"end":{"line":960,"column":27}},"loc":{"start":{"line":960,"column":90},"end":{"line":971,"column":null}},"line":960},"35":{"name":"(anonymous_35)","decl":{"start":{"line":970,"column":20},"end":{"line":970,"column":27}},"loc":{"start":{"line":970,"column":27},"end":{"line":970,"column":49}},"line":970},"36":{"name":"(anonymous_36)","decl":{"start":{"line":979,"column":8},"end":{"line":979,"column":29}},"loc":{"start":{"line":979,"column":90},"end":{"line":1015,"column":null}},"line":979},"37":{"name":"(anonymous_37)","decl":{"start":{"line":1004,"column":80},"end":{"line":1004,"column":86}},"loc":{"start":{"line":1004,"column":86},"end":{"line":1004,"column":90}},"line":1004},"38":{"name":"(anonymous_38)","decl":{"start":{"line":1017,"column":10},"end":{"line":1017,"column":23}},"loc":{"start":{"line":1017,"column":69},"end":{"line":1031,"column":null}},"line":1017},"39":{"name":"(anonymous_39)","decl":{"start":{"line":1035,"column":16},"end":{"line":1035,"column":51}},"loc":{"start":{"line":1035,"column":51},"end":{"line":1039,"column":null}},"line":1035},"40":{"name":"(anonymous_40)","decl":{"start":{"line":1041,"column":10},"end":{"line":1041,"column":31}},"loc":{"start":{"line":1041,"column":31},"end":{"line":1140,"column":null}},"line":1041},"41":{"name":"(anonymous_41)","decl":{"start":{"line":1145,"column":10},"end":{"line":1145,"column":32}},"loc":{"start":{"line":1145,"column":32},"end":{"line":1178,"column":null}},"line":1145},"42":{"name":"(anonymous_42)","decl":{"start":{"line":1150,"column":49},"end":{"line":1150,"column":54}},"loc":{"start":{"line":1150,"column":54},"end":{"line":1150,"column":60}},"line":1150},"43":{"name":"(anonymous_43)","decl":{"start":{"line":1171,"column":23},"end":{"line":1171,"column":28}},"loc":{"start":{"line":1171,"column":28},"end":{"line":1171,"column":50}},"line":1171},"44":{"name":"(anonymous_44)","decl":{"start":{"line":1180,"column":10},"end":{"line":1180,"column":23}},"loc":{"start":{"line":1180,"column":68},"end":{"line":1192,"column":null}},"line":1180},"45":{"name":"(anonymous_45)","decl":{"start":{"line":1194,"column":10},"end":{"line":1194,"column":27}},"loc":{"start":{"line":1194,"column":70},"end":{"line":1214,"column":null}},"line":1194},"46":{"name":"(anonymous_46)","decl":{"start":{"line":1216,"column":10},"end":{"line":1216,"column":29}},"loc":{"start":{"line":1216,"column":56},"end":{"line":1230,"column":null}},"line":1216},"47":{"name":"(anonymous_47)","decl":{"start":{"line":1232,"column":10},"end":{"line":1232,"column":29}},"loc":{"start":{"line":1232,"column":51},"end":{"line":1240,"column":null}},"line":1232},"48":{"name":"createHookService","decl":{"start":{"line":1246,"column":16},"end":{"line":1246,"column":34}},"loc":{"start":{"line":1246,"column":66},"end":{"line":1248,"column":null}},"line":1246},"49":{"name":"extractLastAssistantMessage","decl":{"start":{"line":1255,"column":16},"end":{"line":1255,"column":44}},"loc":{"start":{"line":1255,"column":83},"end":{"line":1302,"column":null}},"line":1255},"50":{"name":"(anonymous_50)","decl":{"start":{"line":1279,"column":20},"end":{"line":1279,"column":21}},"loc":{"start":{"line":1279,"column":45},"end":{"line":1279,"column":62}},"line":1279},"51":{"name":"(anonymous_51)","decl":{"start":{"line":1280,"column":17},"end":{"line":1280,"column":18}},"loc":{"start":{"line":1280,"column":42},"end":{"line":1280,"column":48}},"line":1280}},"branchMap":{"0":{"loc":{"start":{"line":73,"column":27},"end":{"line":73,"column":74}},"type":"default-arg","locations":[{"start":{"line":73,"column":70},"end":{"line":73,"column":74}}],"line":73},"1":{"loc":{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},"type":"if","locations":[{"start":{"line":82,"column":4},"end":{"line":82,"column":null}},{"start":{},"end":{}}],"line":82},"2":{"loc":{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},"type":"if","locations":[{"start":{"line":86,"column":4},"end":{"line":88,"column":null}},{"start":{},"end":{}}],"line":86},"3":{"loc":{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},"type":"if","locations":[{"start":{"line":108,"column":4},"end":{"line":108,"column":null}},{"start":{},"end":{}}],"line":108},"4":{"loc":{"start":{"line":108,"column":8},"end":{"line":108,"column":39}},"type":"binary-expr","locations":[{"start":{"line":108,"column":8},"end":{"line":108,"column":29}},{"start":{"line":108,"column":29},"end":{"line":108,"column":39}}],"line":108},"5":{"loc":{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},"type":"if","locations":[{"start":{"line":125,"column":4},"end":{"line":127,"column":null}},{"start":{},"end":{}}],"line":125},"6":{"loc":{"start":{"line":134,"column":31},"end":{"line":134,"column":45}},"type":"binary-expr","locations":[{"start":{"line":134,"column":31},"end":{"line":134,"column":41}},{"start":{"line":134,"column":41},"end":{"line":134,"column":45}}],"line":134},"7":{"loc":{"start":{"line":140,"column":14},"end":{"line":140,"column":null}},"type":"binary-expr","locations":[{"start":{"line":140,"column":14},"end":{"line":140,"column":24}},{"start":{"line":140,"column":24},"end":{"line":140,"column":null}}],"line":140},"8":{"loc":{"start":{"line":167,"column":15},"end":{"line":167,"column":null}},"type":"cond-expr","locations":[{"start":{"line":167,"column":36},"end":{"line":167,"column":69}},{"start":{"line":167,"column":69},"end":{"line":167,"column":null}}],"line":167},"9":{"loc":{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},"type":"if","locations":[{"start":{"line":170,"column":4},"end":{"line":172,"column":null}},{"start":{},"end":{}}],"line":170},"10":{"loc":{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},"type":"if","locations":[{"start":{"line":187,"column":4},"end":{"line":187,"column":null}},{"start":{},"end":{}}],"line":187},"11":{"loc":{"start":{"line":193,"column":11},"end":{"line":193,"column":null}},"type":"binary-expr","locations":[{"start":{"line":193,"column":11},"end":{"line":193,"column":25}},{"start":{"line":193,"column":25},"end":{"line":193,"column":null}}],"line":193},"12":{"loc":{"start":{"line":220,"column":42},"end":{"line":220,"column":85}},"type":"default-arg","locations":[{"start":{"line":220,"column":58},"end":{"line":220,"column":85}}],"line":220},"13":{"loc":{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},"type":"if","locations":[{"start":{"line":244,"column":4},"end":{"line":244,"column":null}},{"start":{},"end":{}}],"line":244},"14":{"loc":{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},"type":"if","locations":[{"start":{"line":248,"column":4},"end":{"line":250,"column":null}},{"start":{},"end":{}}],"line":248},"15":{"loc":{"start":{"line":266,"column":16},"end":{"line":266,"column":31}},"type":"binary-expr","locations":[{"start":{"line":266,"column":16},"end":{"line":266,"column":27}},{"start":{"line":266,"column":27},"end":{"line":266,"column":31}}],"line":266},"16":{"loc":{"start":{"line":272,"column":43},"end":{"line":272,"column":88}},"type":"default-arg","locations":[{"start":{"line":272,"column":59},"end":{"line":272,"column":88}}],"line":272},"17":{"loc":{"start":{"line":308,"column":36},"end":{"line":308,"column":51}},"type":"binary-expr","locations":[{"start":{"line":308,"column":36},"end":{"line":308,"column":49}},{"start":{"line":308,"column":49},"end":{"line":308,"column":51}}],"line":308},"18":{"loc":{"start":{"line":310,"column":21},"end":{"line":310,"column":39}},"type":"binary-expr","locations":[{"start":{"line":310,"column":21},"end":{"line":310,"column":37}},{"start":{"line":310,"column":37},"end":{"line":310,"column":39}}],"line":310},"19":{"loc":{"start":{"line":324,"column":91},"end":{"line":324,"column":113}},"type":"binary-expr","locations":[{"start":{"line":324,"column":91},"end":{"line":324,"column":107}},{"start":{"line":324,"column":107},"end":{"line":324,"column":113}}],"line":324},"20":{"loc":{"start":{"line":348,"column":20},"end":{"line":348,"column":null}},"type":"binary-expr","locations":[{"start":{"line":348,"column":20},"end":{"line":348,"column":36}},{"start":{"line":348,"column":36},"end":{"line":348,"column":null}}],"line":348},"21":{"loc":{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":370,"column":4},"end":{"line":370,"column":null}},{"start":{},"end":{}}],"line":370},"22":{"loc":{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},"type":"if","locations":[{"start":{"line":373,"column":4},"end":{"line":373,"column":null}},{"start":{},"end":{}}],"line":373},"23":{"loc":{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},"type":"if","locations":[{"start":{"line":397,"column":4},"end":{"line":409,"column":null}},{"start":{"line":404,"column":4},"end":{"line":409,"column":null}}],"line":397},"24":{"loc":{"start":{"line":400,"column":37},"end":{"line":400,"column":68}},"type":"binary-expr","locations":[{"start":{"line":400,"column":37},"end":{"line":400,"column":64}},{"start":{"line":400,"column":64},"end":{"line":400,"column":68}}],"line":400},"25":{"loc":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"type":"if","locations":[{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},{"start":{},"end":{}}],"line":401},"26":{"loc":{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},"type":"if","locations":[{"start":{"line":404,"column":4},"end":{"line":409,"column":null}},{"start":{"line":406,"column":11},"end":{"line":409,"column":null}}],"line":404},"27":{"loc":{"start":{"line":405,"column":15},"end":{"line":405,"column":49}},"type":"binary-expr","locations":[{"start":{"line":405,"column":15},"end":{"line":405,"column":45}},{"start":{"line":405,"column":45},"end":{"line":405,"column":49}}],"line":405},"28":{"loc":{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},"type":"if","locations":[{"start":{"line":426,"column":4},"end":{"line":426,"column":null}},{"start":{},"end":{}}],"line":426},"29":{"loc":{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},"type":"if","locations":[{"start":{"line":442,"column":4},"end":{"line":459,"column":null}},{"start":{},"end":{}}],"line":442},"30":{"loc":{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},"type":"if","locations":[{"start":{"line":445,"column":8},"end":{"line":455,"column":null}},{"start":{"line":453,"column":15},"end":{"line":455,"column":null}}],"line":445},"31":{"loc":{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},"type":"if","locations":[{"start":{"line":522,"column":8},"end":{"line":524,"column":null}},{"start":{},"end":{}}],"line":522},"32":{"loc":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"type":"if","locations":[{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},{"start":{},"end":{}}],"line":532},"33":{"loc":{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},"type":"if","locations":[{"start":{"line":535,"column":8},"end":{"line":538,"column":null}},{"start":{},"end":{}}],"line":535},"34":{"loc":{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},"type":"if","locations":[{"start":{"line":545,"column":10},"end":{"line":548,"column":null}},{"start":{},"end":{}}],"line":545},"35":{"loc":{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},"type":"if","locations":[{"start":{"line":553,"column":10},"end":{"line":556,"column":null}},{"start":{},"end":{}}],"line":553},"36":{"loc":{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},"type":"if","locations":[{"start":{"line":569,"column":6},"end":{"line":593,"column":null}},{"start":{},"end":{}}],"line":569},"37":{"loc":{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},"type":"if","locations":[{"start":{"line":571,"column":10},"end":{"line":571,"column":null}},{"start":{},"end":{}}],"line":571},"38":{"loc":{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},"type":"if","locations":[{"start":{"line":579,"column":14},"end":{"line":579,"column":null}},{"start":{},"end":{}}],"line":579},"39":{"loc":{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},"type":"if","locations":[{"start":{"line":583,"column":14},"end":{"line":583,"column":null}},{"start":{},"end":{}}],"line":583},"40":{"loc":{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},"type":"if","locations":[{"start":{"line":619,"column":8},"end":{"line":621,"column":null}},{"start":{},"end":{}}],"line":619},"41":{"loc":{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},"type":"if","locations":[{"start":{"line":628,"column":8},"end":{"line":628,"column":null}},{"start":{},"end":{}}],"line":628},"42":{"loc":{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},"type":"if","locations":[{"start":{"line":631,"column":10},"end":{"line":636,"column":null}},{"start":{"line":633,"column":10},"end":{"line":636,"column":null}}],"line":631},"43":{"loc":{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},"type":"if","locations":[{"start":{"line":633,"column":10},"end":{"line":636,"column":null}},{"start":{},"end":{}}],"line":633},"44":{"loc":{"start":{"line":653,"column":50},"end":{"line":653,"column":94}},"type":"default-arg","locations":[{"start":{"line":653,"column":66},"end":{"line":653,"column":94}}],"line":653},"45":{"loc":{"start":{"line":669,"column":47},"end":{"line":669,"column":91}},"type":"default-arg","locations":[{"start":{"line":669,"column":63},"end":{"line":669,"column":91}}],"line":669},"46":{"loc":{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},"type":"if","locations":[{"start":{"line":739,"column":4},"end":{"line":760,"column":null}},{"start":{},"end":{}}],"line":739},"47":{"loc":{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},"type":"if","locations":[{"start":{"line":746,"column":8},"end":{"line":748,"column":null}},{"start":{},"end":{}}],"line":746},"48":{"loc":{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},"type":"if","locations":[{"start":{"line":749,"column":8},"end":{"line":751,"column":null}},{"start":{},"end":{}}],"line":749},"49":{"loc":{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},"type":"if","locations":[{"start":{"line":752,"column":8},"end":{"line":754,"column":null}},{"start":{},"end":{}}],"line":752},"50":{"loc":{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},"type":"if","locations":[{"start":{"line":755,"column":8},"end":{"line":757,"column":null}},{"start":{},"end":{}}],"line":755},"51":{"loc":{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},"type":"if","locations":[{"start":{"line":763,"column":4},"end":{"line":772,"column":null}},{"start":{},"end":{}}],"line":763},"52":{"loc":{"start":{"line":769,"column":72},"end":{"line":769,"column":115}},"type":"cond-expr","locations":[{"start":{"line":769,"column":105},"end":{"line":769,"column":113}},{"start":{"line":769,"column":113},"end":{"line":769,"column":115}}],"line":769},"53":{"loc":{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},"type":"if","locations":[{"start":{"line":775,"column":4},"end":{"line":792,"column":null}},{"start":{},"end":{}}],"line":775},"54":{"loc":{"start":{"line":782,"column":23},"end":{"line":782,"column":null}},"type":"binary-expr","locations":[{"start":{"line":782,"column":23},"end":{"line":782,"column":39}},{"start":{"line":782,"column":39},"end":{"line":782,"column":52}},{"start":{"line":782,"column":52},"end":{"line":782,"column":null}}],"line":782},"55":{"loc":{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},"type":"if","locations":[{"start":{"line":784,"column":8},"end":{"line":786,"column":null}},{"start":{},"end":{}}],"line":784},"56":{"loc":{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},"type":"if","locations":[{"start":{"line":787,"column":8},"end":{"line":789,"column":null}},{"start":{},"end":{}}],"line":787},"57":{"loc":{"start":{"line":787,"column":12},"end":{"line":787,"column":53}},"type":"binary-expr","locations":[{"start":{"line":787,"column":12},"end":{"line":787,"column":28}},{"start":{"line":787,"column":28},"end":{"line":787,"column":53}}],"line":787},"58":{"loc":{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},"type":"if","locations":[{"start":{"line":795,"column":4},"end":{"line":815,"column":null}},{"start":{},"end":{}}],"line":795},"59":{"loc":{"start":{"line":795,"column":8},"end":{"line":795,"column":55}},"type":"binary-expr","locations":[{"start":{"line":795,"column":8},"end":{"line":795,"column":34}},{"start":{"line":795,"column":34},"end":{"line":795,"column":55}}],"line":795},"60":{"loc":{"start":{"line":801,"column":23},"end":{"line":801,"column":null}},"type":"cond-expr","locations":[{"start":{"line":801,"column":56},"end":{"line":801,"column":62}},{"start":{"line":801,"column":62},"end":{"line":801,"column":null}}],"line":801},"61":{"loc":{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},"type":"if","locations":[{"start":{"line":804,"column":8},"end":{"line":806,"column":null}},{"start":{},"end":{}}],"line":804},"62":{"loc":{"start":{"line":805,"column":69},"end":{"line":805,"column":109}},"type":"cond-expr","locations":[{"start":{"line":805,"column":99},"end":{"line":805,"column":107}},{"start":{"line":805,"column":107},"end":{"line":805,"column":109}}],"line":805},"63":{"loc":{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},"type":"if","locations":[{"start":{"line":808,"column":8},"end":{"line":810,"column":null}},{"start":{},"end":{}}],"line":808},"64":{"loc":{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},"type":"if","locations":[{"start":{"line":818,"column":4},"end":{"line":821,"column":null}},{"start":{},"end":{}}],"line":818},"65":{"loc":{"start":{"line":818,"column":8},"end":{"line":818,"column":84}},"type":"binary-expr","locations":[{"start":{"line":818,"column":8},"end":{"line":818,"column":37}},{"start":{"line":818,"column":37},"end":{"line":818,"column":62}},{"start":{"line":818,"column":62},"end":{"line":818,"column":84}}],"line":818},"66":{"loc":{"start":{"line":825,"column":26},"end":{"line":825,"column":null}},"type":"binary-expr","locations":[{"start":{"line":825,"column":26},"end":{"line":825,"column":46}},{"start":{"line":825,"column":46},"end":{"line":825,"column":null}}],"line":825},"67":{"loc":{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},"type":"if","locations":[{"start":{"line":826,"column":4},"end":{"line":832,"column":null}},{"start":{},"end":{}}],"line":826},"68":{"loc":{"start":{"line":826,"column":8},"end":{"line":826,"column":43}},"type":"binary-expr","locations":[{"start":{"line":826,"column":8},"end":{"line":826,"column":24}},{"start":{"line":826,"column":24},"end":{"line":826,"column":43}}],"line":826},"69":{"loc":{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},"type":"if","locations":[{"start":{"line":844,"column":4},"end":{"line":844,"column":null}},{"start":{},"end":{}}],"line":844},"70":{"loc":{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},"type":"if","locations":[{"start":{"line":845,"column":4},"end":{"line":845,"column":null}},{"start":{},"end":{}}],"line":845},"71":{"loc":{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},"type":"if","locations":[{"start":{"line":846,"column":4},"end":{"line":848,"column":null}},{"start":{},"end":{}}],"line":846},"72":{"loc":{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},"type":"if","locations":[{"start":{"line":849,"column":4},"end":{"line":849,"column":null}},{"start":{},"end":{}}],"line":849},"73":{"loc":{"start":{"line":850,"column":11},"end":{"line":850,"column":null}},"type":"binary-expr","locations":[{"start":{"line":850,"column":11},"end":{"line":850,"column":31}},{"start":{"line":850,"column":31},"end":{"line":850,"column":null}}],"line":850},"74":{"loc":{"start":{"line":869,"column":25},"end":{"line":869,"column":null}},"type":"binary-expr","locations":[{"start":{"line":869,"column":25},"end":{"line":869,"column":44}},{"start":{"line":869,"column":44},"end":{"line":869,"column":58}},{"start":{"line":869,"column":58},"end":{"line":869,"column":null}}],"line":869},"75":{"loc":{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":871,"column":8},"end":{"line":877,"column":null}},{"start":{"line":873,"column":8},"end":{"line":877,"column":null}}],"line":871},"76":{"loc":{"start":{"line":871,"column":12},"end":{"line":871,"column":45}},"type":"binary-expr","locations":[{"start":{"line":871,"column":12},"end":{"line":871,"column":35}},{"start":{"line":871,"column":35},"end":{"line":871,"column":45}}],"line":871},"77":{"loc":{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":873,"column":8},"end":{"line":877,"column":null}},{"start":{"line":875,"column":8},"end":{"line":877,"column":null}}],"line":873},"78":{"loc":{"start":{"line":873,"column":19},"end":{"line":873,"column":53}},"type":"binary-expr","locations":[{"start":{"line":873,"column":19},"end":{"line":873,"column":43}},{"start":{"line":873,"column":43},"end":{"line":873,"column":53}}],"line":873},"79":{"loc":{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},"type":"if","locations":[{"start":{"line":875,"column":8},"end":{"line":877,"column":null}},{"start":{},"end":{}}],"line":875},"80":{"loc":{"start":{"line":875,"column":19},"end":{"line":875,"column":60}},"type":"binary-expr","locations":[{"start":{"line":875,"column":19},"end":{"line":875,"column":45}},{"start":{"line":875,"column":45},"end":{"line":875,"column":60}}],"line":875},"81":{"loc":{"start":{"line":884,"column":20},"end":{"line":886,"column":null}},"type":"cond-expr","locations":[{"start":{"line":885,"column":8},"end":{"line":885,"column":null}},{"start":{"line":886,"column":8},"end":{"line":886,"column":null}}],"line":884},"82":{"loc":{"start":{"line":886,"column":8},"end":{"line":886,"column":null}},"type":"binary-expr","locations":[{"start":{"line":886,"column":8},"end":{"line":886,"column":27}},{"start":{"line":886,"column":27},"end":{"line":886,"column":null}}],"line":886},"83":{"loc":{"start":{"line":891,"column":26},"end":{"line":891,"column":51}},"type":"binary-expr","locations":[{"start":{"line":891,"column":26},"end":{"line":891,"column":46}},{"start":{"line":891,"column":46},"end":{"line":891,"column":51}}],"line":891},"84":{"loc":{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},"type":"if","locations":[{"start":{"line":894,"column":4},"end":{"line":894,"column":null}},{"start":{},"end":{}}],"line":894},"85":{"loc":{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},"type":"if","locations":[{"start":{"line":895,"column":4},"end":{"line":895,"column":null}},{"start":{},"end":{}}],"line":895},"86":{"loc":{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},"type":"if","locations":[{"start":{"line":896,"column":4},"end":{"line":896,"column":null}},{"start":{},"end":{}}],"line":896},"87":{"loc":{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},"type":"if","locations":[{"start":{"line":897,"column":4},"end":{"line":897,"column":null}},{"start":{},"end":{}}],"line":897},"88":{"loc":{"start":{"line":900,"column":18},"end":{"line":902,"column":null}},"type":"cond-expr","locations":[{"start":{"line":901,"column":8},"end":{"line":901,"column":null}},{"start":{"line":902,"column":8},"end":{"line":902,"column":null}}],"line":900},"89":{"loc":{"start":{"line":901,"column":55},"end":{"line":901,"column":115}},"type":"cond-expr","locations":[{"start":{"line":901,"column":77},"end":{"line":901,"column":113}},{"start":{"line":901,"column":113},"end":{"line":901,"column":115}}],"line":901},"90":{"loc":{"start":{"line":906,"column":15},"end":{"line":906,"column":null}},"type":"binary-expr","locations":[{"start":{"line":906,"column":15},"end":{"line":906,"column":35}},{"start":{"line":906,"column":35},"end":{"line":906,"column":null}}],"line":906},"91":{"loc":{"start":{"line":908,"column":17},"end":{"line":908,"column":null}},"type":"binary-expr","locations":[{"start":{"line":908,"column":17},"end":{"line":908,"column":46}},{"start":{"line":908,"column":46},"end":{"line":908,"column":null}}],"line":908},"92":{"loc":{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},"type":"if","locations":[{"start":{"line":946,"column":4},"end":{"line":948,"column":null}},{"start":{},"end":{}}],"line":946},"93":{"loc":{"start":{"line":960,"column":44},"end":{"line":960,"column":90}},"type":"default-arg","locations":[{"start":{"line":960,"column":60},"end":{"line":960,"column":90}}],"line":960},"94":{"loc":{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},"type":"if","locations":[{"start":{"line":987,"column":4},"end":{"line":987,"column":null}},{"start":{},"end":{}}],"line":987},"95":{"loc":{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},"type":"if","locations":[{"start":{"line":993,"column":4},"end":{"line":993,"column":null}},{"start":{},"end":{}}],"line":993},"96":{"loc":{"start":{"line":997,"column":6},"end":{"line":997,"column":null}},"type":"cond-expr","locations":[{"start":{"line":997,"column":24},"end":{"line":997,"column":56}},{"start":{"line":997,"column":56},"end":{"line":997,"column":null}}],"line":997},"97":{"loc":{"start":{"line":998,"column":6},"end":{"line":998,"column":null}},"type":"cond-expr","locations":[{"start":{"line":998,"column":26},"end":{"line":998,"column":62}},{"start":{"line":998,"column":62},"end":{"line":998,"column":null}}],"line":998},"98":{"loc":{"start":{"line":999,"column":6},"end":{"line":999,"column":null}},"type":"cond-expr","locations":[{"start":{"line":999,"column":41},"end":{"line":999,"column":97}},{"start":{"line":999,"column":97},"end":{"line":999,"column":null}}],"line":999},"99":{"loc":{"start":{"line":1000,"column":6},"end":{"line":1000,"column":null}},"type":"cond-expr","locations":[{"start":{"line":1000,"column":22},"end":{"line":1000,"column":50}},{"start":{"line":1000,"column":50},"end":{"line":1000,"column":null}}],"line":1000},"100":{"loc":{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},"type":"if","locations":[{"start":{"line":1005,"column":4},"end":{"line":1005,"column":null}},{"start":{},"end":{}}],"line":1005},"101":{"loc":{"start":{"line":1022,"column":15},"end":{"line":1022,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1022,"column":15},"end":{"line":1022,"column":40}},{"start":{"line":1022,"column":40},"end":{"line":1022,"column":null}}],"line":1022},"102":{"loc":{"start":{"line":1023,"column":17},"end":{"line":1023,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1023,"column":17},"end":{"line":1023,"column":44}},{"start":{"line":1023,"column":44},"end":{"line":1023,"column":null}}],"line":1023},"103":{"loc":{"start":{"line":1024,"column":29},"end":{"line":1024,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1024,"column":29},"end":{"line":1024,"column":58}},{"start":{"line":1024,"column":58},"end":{"line":1024,"column":62}}],"line":1024},"104":{"loc":{"start":{"line":1025,"column":33},"end":{"line":1025,"column":70}},"type":"binary-expr","locations":[{"start":{"line":1025,"column":33},"end":{"line":1025,"column":66}},{"start":{"line":1025,"column":66},"end":{"line":1025,"column":70}}],"line":1025},"105":{"loc":{"start":{"line":1026,"column":17},"end":{"line":1026,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1026,"column":17},"end":{"line":1026,"column":45}},{"start":{"line":1026,"column":45},"end":{"line":1026,"column":null}}],"line":1026},"106":{"loc":{"start":{"line":1027,"column":13},"end":{"line":1027,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1027,"column":13},"end":{"line":1027,"column":36}},{"start":{"line":1027,"column":36},"end":{"line":1027,"column":null}}],"line":1027},"107":{"loc":{"start":{"line":1028,"column":20},"end":{"line":1028,"column":null}},"type":"binary-expr","locations":[{"start":{"line":1028,"column":20},"end":{"line":1028,"column":51}},{"start":{"line":1028,"column":51},"end":{"line":1028,"column":null}}],"line":1028},"108":{"loc":{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},"type":"if","locations":[{"start":{"line":1036,"column":4},"end":{"line":1038,"column":null}},{"start":{},"end":{}}],"line":1036},"109":{"loc":{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},"type":"if","locations":[{"start":{"line":1042,"column":4},"end":{"line":1042,"column":null}},{"start":{},"end":{}}],"line":1042},"110":{"loc":{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},"type":"if","locations":[{"start":{"line":1146,"column":4},"end":{"line":1146,"column":null}},{"start":{},"end":{}}],"line":1146},"111":{"loc":{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},"type":"if","locations":[{"start":{"line":1163,"column":8},"end":{"line":1165,"column":null}},{"start":{},"end":{}}],"line":1163},"112":{"loc":{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},"type":"if","locations":[{"start":{"line":1171,"column":8},"end":{"line":1173,"column":null}},{"start":{},"end":{}}],"line":1171},"113":{"loc":{"start":{"line":1207,"column":29},"end":{"line":1207,"column":62}},"type":"binary-expr","locations":[{"start":{"line":1207,"column":29},"end":{"line":1207,"column":58}},{"start":{"line":1207,"column":58},"end":{"line":1207,"column":62}}],"line":1207},"114":{"loc":{"start":{"line":1208,"column":33},"end":{"line":1208,"column":70}},"type":"binary-expr","locations":[{"start":{"line":1208,"column":33},"end":{"line":1208,"column":66}},{"start":{"line":1208,"column":66},"end":{"line":1208,"column":70}}],"line":1208},"115":{"loc":{"start":{"line":1211,"column":25},"end":{"line":1211,"column":53}},"type":"binary-expr","locations":[{"start":{"line":1211,"column":25},"end":{"line":1211,"column":49}},{"start":{"line":1211,"column":49},"end":{"line":1211,"column":53}}],"line":1211},"116":{"loc":{"start":{"line":1212,"column":28},"end":{"line":1212,"column":59}},"type":"binary-expr","locations":[{"start":{"line":1212,"column":28},"end":{"line":1212,"column":55}},{"start":{"line":1212,"column":55},"end":{"line":1212,"column":59}}],"line":1212},"117":{"loc":{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},"type":"if","locations":[{"start":{"line":1224,"column":4},"end":{"line":1224,"column":null}},{"start":{},"end":{}}],"line":1224},"118":{"loc":{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},"type":"if","locations":[{"start":{"line":1225,"column":4},"end":{"line":1225,"column":null}},{"start":{},"end":{}}],"line":1225},"119":{"loc":{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},"type":"if","locations":[{"start":{"line":1226,"column":4},"end":{"line":1226,"column":null}},{"start":{},"end":{}}],"line":1226},"120":{"loc":{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},"type":"if","locations":[{"start":{"line":1227,"column":4},"end":{"line":1227,"column":null}},{"start":{},"end":{}}],"line":1227},"121":{"loc":{"start":{"line":1233,"column":4},"end":{"line":1239,"column":null}},"type":"switch","locations":[{"start":{"line":1234,"column":6},"end":{"line":1234,"column":null}},{"start":{"line":1235,"column":6},"end":{"line":1235,"column":null}},{"start":{"line":1236,"column":6},"end":{"line":1236,"column":null}},{"start":{"line":1237,"column":6},"end":{"line":1237,"column":null}},{"start":{"line":1238,"column":6},"end":{"line":1238,"column":null}}],"line":1233},"122":{"loc":{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},"type":"if","locations":[{"start":{"line":1256,"column":2},"end":{"line":1256,"column":null}},{"start":{},"end":{}}],"line":1256},"123":{"loc":{"start":{"line":1256,"column":6},"end":{"line":1256,"column":54}},"type":"binary-expr","locations":[{"start":{"line":1256,"column":6},"end":{"line":1256,"column":25}},{"start":{"line":1256,"column":25},"end":{"line":1256,"column":54}}],"line":1256},"124":{"loc":{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},"type":"if","locations":[{"start":{"line":1260,"column":4},"end":{"line":1260,"column":null}},{"start":{},"end":{}}],"line":1260},"125":{"loc":{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},"type":"if","locations":[{"start":{"line":1268,"column":8},"end":{"line":1268,"column":null}},{"start":{},"end":{}}],"line":1268},"126":{"loc":{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},"type":"if","locations":[{"start":{"line":1271,"column":8},"end":{"line":1271,"column":null}},{"start":{},"end":{}}],"line":1271},"127":{"loc":{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},"type":"if","locations":[{"start":{"line":1274,"column":8},"end":{"line":1282,"column":null}},{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}}],"line":1274},"128":{"loc":{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},"type":"if","locations":[{"start":{"line":1276,"column":8},"end":{"line":1282,"column":null}},{"start":{},"end":{}}],"line":1276},"129":{"loc":{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},"type":"if","locations":[{"start":{"line":1284,"column":8},"end":{"line":1284,"column":null}},{"start":{},"end":{}}],"line":1284}},"s":{"0":4,"1":150,"2":150,"3":150,"4":146,"5":146,"6":196,"7":54,"8":142,"9":142,"10":101,"11":142,"12":142,"13":142,"14":142,"15":142,"16":186,"17":44,"18":142,"19":142,"20":142,"21":186,"22":186,"23":186,"24":97,"25":89,"26":89,"27":186,"28":30,"29":30,"30":30,"31":30,"32":30,"33":30,"34":30,"35":30,"36":30,"37":165,"38":1,"39":164,"40":164,"41":21,"42":21,"43":21,"44":13,"45":23,"46":23,"47":23,"48":7,"49":221,"50":0,"51":221,"52":221,"53":129,"54":92,"55":10,"56":10,"57":10,"58":25,"59":25,"60":25,"61":23,"62":132,"63":132,"64":132,"65":132,"66":132,"67":132,"68":132,"69":132,"70":132,"71":132,"72":132,"73":132,"74":132,"75":132,"76":132,"77":132,"78":132,"79":132,"80":6,"81":6,"82":6,"83":1,"84":5,"85":0,"86":5,"87":3,"88":2,"89":2,"90":0,"91":0,"92":0,"93":0,"94":0,"95":0,"96":0,"97":0,"98":0,"99":0,"100":0,"101":314,"102":1,"103":313,"104":8,"105":8,"106":4,"107":4,"108":4,"109":2,"110":2,"111":2,"112":1,"113":1,"114":2,"115":2,"116":0,"117":0,"118":7,"119":7,"120":0,"121":7,"122":7,"123":7,"124":0,"125":0,"126":7,"127":7,"128":7,"129":7,"130":0,"131":0,"132":3,"133":3,"134":3,"135":3,"136":3,"137":3,"138":3,"139":3,"140":3,"141":3,"142":3,"143":4,"144":4,"145":1,"146":4,"147":3,"148":4,"149":4,"150":3,"151":1,"152":1,"153":1,"154":1,"155":0,"156":0,"157":0,"158":0,"159":0,"160":0,"161":0,"162":0,"163":0,"164":0,"165":0,"166":0,"167":0,"168":0,"169":0,"170":3,"171":3,"172":9,"173":0,"174":9,"175":9,"176":9,"177":9,"178":0,"179":0,"180":0,"181":0,"182":0,"183":0,"184":0,"185":0,"186":0,"187":0,"188":3,"189":3,"190":3,"191":6,"192":6,"193":6,"194":6,"195":6,"196":6,"197":9,"198":9,"199":3,"200":9,"201":6,"202":9,"203":9,"204":6,"205":3,"206":3,"207":2,"208":1,"209":3,"210":3,"211":0,"212":6,"213":6,"214":6,"215":33,"216":33,"217":33,"218":114,"219":24,"220":24,"221":24,"222":24,"223":21,"224":21,"225":21,"226":21,"227":21,"228":21,"229":21,"230":21,"231":21,"232":21,"233":21,"234":21,"235":21,"236":21,"237":21,"238":2,"239":2,"240":2,"241":2,"242":2,"243":2,"244":2,"245":2,"246":2,"247":2,"248":2,"249":2,"250":1,"251":2,"252":21,"253":3,"254":3,"255":3,"256":4,"257":4,"258":3,"259":21,"260":15,"261":15,"262":15,"263":20,"264":20,"265":20,"266":20,"267":20,"268":20,"269":20,"270":8,"271":15,"272":21,"273":15,"274":15,"275":15,"276":15,"277":15,"278":15,"279":15,"280":7,"281":15,"282":5,"283":15,"284":15,"285":21,"286":4,"287":4,"288":21,"289":21,"290":21,"291":17,"292":17,"293":17,"294":17,"295":17,"296":21,"297":8,"298":8,"299":8,"300":3,"301":8,"302":8,"303":8,"304":6,"305":8,"306":0,"307":8,"308":17,"309":17,"310":17,"311":17,"312":17,"313":17,"314":17,"315":45,"316":45,"317":45,"318":45,"319":9,"320":36,"321":17,"322":19,"323":16,"324":17,"325":8,"326":17,"327":17,"328":45,"329":17,"330":17,"331":10,"332":17,"333":6,"334":17,"335":8,"336":17,"337":2,"338":17,"339":17,"340":16,"341":16,"342":16,"343":16,"344":16,"345":16,"346":16,"347":26,"348":26,"349":26,"350":9,"351":4,"352":4,"353":4,"354":1,"355":3,"356":3,"357":3,"358":1,"359":2,"360":4,"361":0,"362":2,"363":2,"364":1,"365":1,"366":12,"367":566,"368":36,"369":142,"370":0,"371":142,"372":142,"373":142,"374":142,"375":142,"376":142,"377":142,"378":142,"379":142,"380":142,"381":142,"382":142,"383":142,"384":142,"385":142,"386":0,"387":142,"388":142,"389":142,"390":2556,"391":142,"392":142,"393":994,"394":0,"395":142,"396":426,"397":426,"398":5112,"399":0,"400":152,"401":138,"402":41,"403":41,"404":41,"405":41,"406":41,"407":41,"408":33,"409":8,"410":8,"411":6,"412":6,"413":4,"414":4,"415":2,"416":20,"417":13,"418":3,"419":1,"420":1,"421":2,"422":1,"423":15,"424":2,"425":13,"426":13,"427":13,"428":1,"429":12,"430":12,"431":12,"432":16,"433":16,"434":16,"435":2,"436":13,"437":16,"438":2,"439":11,"440":11,"441":9,"442":2,"443":2,"444":4,"445":2,"446":11,"447":1,"448":10,"449":10,"450":10,"451":1,"452":6,"453":0},"f":{"0":146,"1":196,"2":186,"3":186,"4":30,"5":165,"6":21,"7":13,"8":23,"9":7,"10":221,"11":10,"12":25,"13":23,"14":132,"15":6,"16":0,"17":0,"18":314,"19":8,"20":3,"21":4,"22":6,"23":9,"24":33,"25":114,"26":24,"27":24,"28":21,"29":21,"30":8,"31":17,"32":8,"33":16,"34":26,"35":9,"36":4,"37":0,"38":12,"39":566,"40":142,"41":142,"42":2556,"43":5112,"44":152,"45":138,"46":41,"47":20,"48":1,"49":15,"50":4,"51":2},"b":{"0":[146],"1":[54,142],"2":[101,41],"3":[44,142],"4":[186,142],"5":[97,89],"6":[89,60],"7":[186,60],"8":[30,0],"9":[30,0],"10":[1,164],"11":[164,139],"12":[23],"13":[0,221],"14":[129,92],"15":[10,0],"16":[25],"17":[132,1],"18":[132,1],"19":[132,116],"20":[132,116],"21":[1,5],"22":[3,2],"23":[0,0],"24":[0,0],"25":[0,0],"26":[0,0],"27":[0,0],"28":[1,313],"29":[4,4],"30":[2,2],"31":[1,3],"32":[3,1],"33":[1,0],"34":[0,0],"35":[0,0],"36":[3,0],"37":[0,9],"38":[0,0],"39":[0,0],"40":[3,6],"41":[6,3],"42":[2,1],"43":[1,0],"44":[33],"45":[24],"46":[2,19],"47":[2,0],"48":[2,0],"49":[2,0],"50":[1,1],"51":[3,18],"52":[0,4],"53":[15,6],"54":[20,0,0],"55":[20,0],"56":[8,12],"57":[20,20],"58":[15,6],"59":[21,19],"60":[5,10],"61":[7,8],"62":[1,6],"63":[5,10],"64":[4,17],"65":[21,6,4],"66":[21,19],"67":[17,4],"68":[21,6],"69":[3,5],"70":[8,0],"71":[6,2],"72":[0,8],"73":[8,0],"74":[45,21,19],"75":[9,36],"76":[45,9],"77":[17,19],"78":[36,17],"79":[16,3],"80":[19,16],"81":[7,10],"82":[10,9],"83":[45,26],"84":[10,7],"85":[6,11],"86":[8,9],"87":[2,15],"88":[8,9],"89":[1,7],"90":[17,0],"91":[17,4],"92":[16,0],"93":[26],"94":[1,3],"95":[1,2],"96":[2,0],"97":[1,1],"98":[1,1],"99":[0,2],"100":[1,1],"101":[12,0],"102":[12,6],"103":[12,0],"104":[12,0],"105":[12,9],"106":[12,10],"107":[12,0],"108":[36,530],"109":[0,142],"110":[0,142],"111":[0,994],"112":[0,426],"113":[138,0],"114":[138,0],"115":[138,0],"116":[138,0],"117":[33,8],"118":[2,6],"119":[2,4],"120":[2,2],"121":[13,3,1,1,2],"122":[2,13],"123":[15,14],"124":[1,12],"125":[2,14],"126":[2,14],"127":[9,2],"128":[2,0],"129":[1,10]},"meta":{"lastBranch":130,"lastFunction":52,"lastStatement":454,"seen":{"s:53:48:59:Infinity":0,"s:69:38:69:Infinity":1,"s:70:33:70:Infinity":2,"s:415:47:415:Infinity":3,"f:73:2:73:14":0,"b:73:70:73:74":0,"s:74:4:74:Infinity":4,"s:75:4:75:Infinity":5,"f:81:8:81:36":1,"b:82:4:82:Infinity:undefined:undefined:undefined:undefined":1,"s:82:4:82:Infinity":6,"s:82:26:82:Infinity":7,"s:85:16:85:Infinity":8,"b:86:4:88:Infinity:undefined:undefined:undefined:undefined":2,"s:86:4:88:Infinity":9,"s:87:6:87:Infinity":10,"s:91:4:91:Infinity":11,"s:94:4:94:Infinity":12,"s:96:4:96:Infinity":13,"s:99:4:99:Infinity":14,"s:101:4:101:Infinity":15,"f:107:8:107:34":2,"b:108:4:108:Infinity:undefined:undefined:undefined:undefined":3,"s:108:4:108:Infinity":16,"b:108:8:108:29:108:29:108:39":4,"s:108:39:108:Infinity":17,"s:110:4:110:Infinity":18,"s:111:4:111:Infinity":19,"s:112:4:112:Infinity":20,"f:120:8:120:20":3,"s:121:4:121:Infinity":21,"s:124:21:124:Infinity":22,"b:125:4:127:Infinity:undefined:undefined:undefined:undefined":5,"s:125:4:127:Infinity":23,"s:126:6:126:Infinity":24,"s:130:16:130:Infinity":25,"s:131:19:134:Infinity":26,"b:134:31:134:41:134:41:134:45":6,"s:136:4:144:Infinity":27,"b:140:14:140:24:140:24:140:Infinity":7,"f:152:8:152:23":4,"s:153:4:153:Infinity":28,"s:156:4:156:Infinity":29,"s:159:25:159:Infinity":30,"s:160:16:160:Infinity":31,"s:162:19:165:Infinity":32,"s:167:15:167:Infinity":33,"b:167:36:167:69:167:69:167:Infinity":8,"b:170:4:172:Infinity:undefined:undefined:undefined:undefined":9,"s:170:4:172:Infinity":34,"s:171:6:171:Infinity":35,"s:174:4:180:Infinity":36,"f:186:2:186:18":5,"b:187:4:187:Infinity:undefined:undefined:undefined:undefined":10,"s:187:4:187:Infinity":37,"s:187:18:187:Infinity":38,"s:189:16:191:Infinity":39,"s:193:4:193:Infinity":40,"b:193:11:193:25:193:25:193:Infinity":11,"f:199:8:199:26":6,"s:200:4:200:Infinity":41,"s:202:17:206:Infinity":42,"s:208:4:214:Infinity":43,"f:208:20:208:28":7,"s:208:28:214:6":44,"f:220:8:220:25":8,"b:220:58:220:85":12,"s:221:4:221:Infinity":45,"s:223:17:229:Infinity":46,"s:231:4:237:Infinity":47,"f:231:20:231:28":9,"s:231:28:237:6":48,"f:243:2:243:13":10,"b:244:4:244:Infinity:undefined:undefined:undefined:undefined":13,"s:244:4:244:Infinity":49,"s:244:18:244:Infinity":50,"s:246:16:246:Infinity":51,"b:248:4:250:Infinity:undefined:undefined:undefined:undefined":14,"s:248:4:250:Infinity":52,"s:249:6:249:Infinity":53,"s:252:4:252:Infinity":54,"f:258:8:258:24":11,"s:259:4:259:Infinity":55,"s:261:16:261:Infinity":56,"s:262:4:266:Infinity":57,"b:266:16:266:27:266:27:266:31":15,"f:272:8:272:26":12,"b:272:59:272:88":16,"s:273:4:273:Infinity":58,"s:275:17:280:Infinity":59,"s:282:4:282:Infinity":60,"f:282:20:282:27":13,"s:282:27:282:49":61,"f:290:8:290:Infinity":14,"s:298:4:298:Infinity":62,"s:300:10:300:Infinity":63,"s:301:16:301:Infinity":64,"s:302:10:302:Infinity":65,"s:303:10:303:Infinity":66,"s:304:25:304:Infinity":67,"s:305:37:305:Infinity":68,"s:308:21:308:Infinity":69,"b:308:36:308:49:308:49:308:51":17,"s:309:10:312:Infinity":70,"b:310:21:310:37:310:37:310:39":18,"s:316:10:316:Infinity":71,"s:317:10:317:Infinity":72,"s:318:10:318:Infinity":73,"s:319:10:319:Infinity":74,"s:321:4:324:Infinity":75,"b:324:91:324:107:324:107:324:113":19,"s:327:4:327:Infinity":76,"s:328:4:328:Infinity":77,"s:331:4:335:Infinity":78,"s:337:4:355:Infinity":79,"b:348:20:348:36:348:36:348:Infinity":20,"f:363:8:363:26":15,"s:364:4:364:Infinity":80,"s:366:16:368:Infinity":81,"b:370:4:370:Infinity:undefined:undefined:undefined:undefined":21,"s:370:4:370:Infinity":82,"s:370:14:370:Infinity":83,"s:372:21:372:Infinity":84,"f:372:96:372:102":16,"s:372:102:372:106":85,"b:373:4:373:Infinity:undefined:undefined:undefined:undefined":22,"s:373:4:373:Infinity":86,"s:373:19:373:Infinity":87,"s:375:4:385:Infinity":88,"s:387:4:387:Infinity":89,"f:393:10:393:Infinity":17,"b:397:4:409:Infinity:404:4:409:Infinity":23,"s:397:4:409:Infinity":90,"s:398:20:398:Infinity":91,"s:399:6:402:Infinity":92,"s:400:25:400:Infinity":93,"b:400:37:400:64:400:64:400:68":24,"b:401:8:401:Infinity:undefined:undefined:undefined:undefined":25,"s:401:8:401:Infinity":94,"s:401:33:401:Infinity":95,"s:403:6:403:Infinity":96,"b:404:4:409:Infinity:406:11:409:Infinity":26,"s:404:4:409:Infinity":97,"s:405:6:405:Infinity":98,"b:405:15:405:45:405:45:405:49":27,"s:407:20:407:Infinity":99,"s:408:6:408:Infinity":100,"f:421:2:421:Infinity":18,"b:426:4:426:Infinity:undefined:undefined:undefined:undefined":28,"s:426:4:426:Infinity":101,"s:426:18:426:Infinity":102,"s:427:4:429:Infinity":103,"f:438:2:438:22":19,"s:439:21:439:Infinity":104,"b:442:4:459:Infinity:undefined:undefined:undefined:undefined":29,"s:442:4:459:Infinity":105,"s:443:6:458:Infinity":106,"s:444:20:444:Infinity":107,"b:445:8:455:Infinity:453:15:455:Infinity":30,"s:445:8:455:Infinity":108,"s:446:10:452:Infinity":109,"s:447:12:447:Infinity":110,"s:448:12:448:Infinity":111,"s:451:12:451:Infinity":112,"s:451:18:451:40":113,"s:454:10:454:Infinity":114,"s:454:16:454:38":115,"s:457:8:457:Infinity":116,"s:457:14:457:36":117,"s:463:4:468:Infinity":118,"s:464:6:464:Infinity":119,"s:467:6:467:Infinity":120,"s:471:4:476:Infinity":121,"s:472:6:472:Infinity":122,"s:473:6:473:Infinity":123,"s:475:6:475:Infinity":124,"s:475:12:475:27":125,"s:479:4:490:Infinity":126,"s:480:22:480:Infinity":127,"s:481:12:485:Infinity":128,"s:486:6:486:Infinity":129,"s:489:6:489:Infinity":130,"s:489:12:489:34":131,"f:498:8:498:49":20,"s:499:4:499:Infinity":132,"s:501:21:501:Infinity":133,"s:502:4:502:Infinity":134,"s:504:16:504:Infinity":135,"s:505:4:596:Infinity":136,"s:506:41:506:Infinity":137,"s:507:23:507:Infinity":138,"s:508:25:508:Infinity":139,"s:509:6:509:Infinity":140,"s:511:47:515:Infinity":141,"s:518:29:526:Infinity":142,"f:518:50:518:56":21,"s:519:21:521:Infinity":143,"b:522:8:524:Infinity:undefined:undefined:undefined:undefined":31,"s:522:8:524:Infinity":144,"s:523:10:523:Infinity":145,"s:525:8:525:Infinity":146,"s:529:6:566:Infinity":147,"s:530:21:530:Infinity":148,"b:532:8:532:Infinity:undefined:undefined:undefined:undefined":32,"s:532:8:532:Infinity":149,"s:532:19:532:Infinity":150,"s:534:22:534:Infinity":151,"b:535:8:538:Infinity:undefined:undefined:undefined:undefined":33,"s:535:8:538:Infinity":152,"s:536:10:536:Infinity":153,"s:537:10:537:Infinity":154,"s:540:8:565:Infinity":155,"s:541:22:543:Infinity":156,"b:545:10:548:Infinity:undefined:undefined:undefined:undefined":34,"s:545:10:548:Infinity":157,"s:546:12:546:Infinity":158,"s:547:12:547:Infinity":159,"s:550:23:552:Infinity":160,"b:553:10:556:Infinity:undefined:undefined:undefined:undefined":35,"s:553:10:556:Infinity":161,"s:554:12:554:Infinity":162,"s:555:12:555:Infinity":163,"s:558:25:558:Infinity":164,"s:559:25:559:Infinity":165,"s:560:10:560:Infinity":166,"s:561:10:561:Infinity":167,"s:562:10:562:Infinity":168,"s:564:10:564:Infinity":169,"b:569:6:593:Infinity:undefined:undefined:undefined:undefined":36,"s:569:6:593:Infinity":170,"s:570:8:592:Infinity":171,"b:571:10:571:Infinity:undefined:undefined:undefined:undefined":37,"s:571:10:571:Infinity":172,"s:571:61:571:Infinity":173,"s:572:10:591:Infinity":174,"s:573:30:573:Infinity":175,"s:574:25:576:Infinity":176,"s:578:12:590:Infinity":177,"b:579:14:579:Infinity:undefined:undefined:undefined:undefined":38,"s:579:14:579:Infinity":178,"s:579:65:579:Infinity":179,"s:580:27:582:Infinity":180,"b:583:14:583:Infinity:undefined:undefined:undefined:undefined":39,"s:583:14:583:Infinity":181,"s:583:25:583:Infinity":182,"s:584:14:589:Infinity":183,"s:585:31:585:Infinity":184,"s:586:31:586:Infinity":185,"s:587:16:587:Infinity":186,"s:588:16:588:Infinity":187,"s:595:6:595:Infinity":188,"s:595:12:595:34":189,"s:598:4:598:Infinity":190,"f:606:8:606:50":22,"s:607:4:607:Infinity":191,"s:609:21:609:Infinity":192,"s:610:4:610:Infinity":193,"s:612:16:612:Infinity":194,"s:613:4:645:Infinity":195,"s:615:30:623:Infinity":196,"f:615:51:615:57":23,"s:616:21:618:Infinity":197,"b:619:8:621:Infinity:undefined:undefined:undefined:undefined":40,"s:619:8:621:Infinity":198,"s:620:10:620:Infinity":199,"s:622:8:622:Infinity":200,"s:625:6:642:Infinity":201,"s:626:21:626:Infinity":202,"b:628:8:628:Infinity:undefined:undefined:undefined:undefined":41,"s:628:8:628:Infinity":203,"s:628:19:628:Infinity":204,"s:630:8:641:Infinity":205,"b:631:10:636:Infinity:633:10:636:Infinity":42,"s:631:10:636:Infinity":206,"s:632:12:632:Infinity":207,"b:633:10:636:Infinity:undefined:undefined:undefined:undefined":43,"s:633:10:636:Infinity":208,"s:637:10:637:Infinity":209,"s:638:10:638:Infinity":210,"s:640:10:640:Infinity":211,"s:644:6:644:Infinity":212,"s:644:12:644:34":213,"s:647:4:647:Infinity":214,"f:653:8:653:31":24,"b:653:66:653:94":44,"s:654:4:654:Infinity":215,"s:656:17:661:Infinity":216,"s:663:4:663:Infinity":217,"f:663:20:663:27":25,"s:663:27:663:53":218,"f:669:8:669:30":26,"b:669:63:669:91":45,"s:670:4:670:Infinity":219,"s:672:17:677:Infinity":220,"s:679:4:679:Infinity":221,"f:679:20:679:27":27,"s:679:27:679:53":222,"f:687:8:687:19":28,"s:688:4:688:Infinity":223,"s:690:31:693:Infinity":224,"s:695:29:698:Infinity":225,"s:700:24:700:Infinity":226,"s:701:29:701:Infinity":227,"s:704:21:706:Infinity":228,"s:708:4:714:Infinity":229,"f:720:10:720:Infinity":29,"s:727:28:727:Infinity":230,"s:729:4:729:Infinity":231,"s:730:4:730:Infinity":232,"s:733:4:733:Infinity":233,"s:734:4:734:Infinity":234,"s:735:4:735:Infinity":235,"s:736:4:736:Infinity":236,"b:739:4:760:Infinity:undefined:undefined:undefined:undefined":46,"s:739:4:760:Infinity":237,"s:740:6:740:Infinity":238,"s:741:6:741:Infinity":239,"s:743:6:759:Infinity":240,"s:744:21:744:Infinity":241,"s:745:8:745:Infinity":242,"b:746:8:748:Infinity:undefined:undefined:undefined:undefined":47,"s:746:8:748:Infinity":243,"s:747:10:747:Infinity":244,"b:749:8:751:Infinity:undefined:undefined:undefined:undefined":48,"s:749:8:751:Infinity":245,"s:750:10:750:Infinity":246,"b:752:8:754:Infinity:undefined:undefined:undefined:undefined":49,"s:752:8:754:Infinity":247,"s:753:10:753:Infinity":248,"b:755:8:757:Infinity:undefined:undefined:undefined:undefined":50,"s:755:8:757:Infinity":249,"s:756:10:756:Infinity":250,"s:758:8:758:Infinity":251,"b:763:4:772:Infinity:undefined:undefined:undefined:undefined":51,"s:763:4:772:Infinity":252,"s:764:6:764:Infinity":253,"s:765:6:765:Infinity":254,"s:767:6:770:Infinity":255,"s:768:21:768:Infinity":256,"s:769:8:769:Infinity":257,"b:769:105:769:113:769:113:769:115":52,"s:771:6:771:Infinity":258,"b:775:4:792:Infinity:undefined:undefined:undefined:undefined":53,"s:775:4:792:Infinity":259,"s:776:6:776:Infinity":260,"s:777:6:777:Infinity":261,"s:779:6:790:Infinity":262,"s:780:21:780:Infinity":263,"s:781:21:781:Infinity":264,"s:782:23:782:Infinity":265,"b:782:23:782:39:782:39:782:52:782:52:782:Infinity":54,"s:783:8:783:Infinity":266,"b:784:8:786:Infinity:undefined:undefined:undefined:undefined":55,"s:784:8:786:Infinity":267,"s:785:10:785:Infinity":268,"b:787:8:789:Infinity:undefined:undefined:undefined:undefined":56,"s:787:8:789:Infinity":269,"b:787:12:787:28:787:28:787:53":57,"s:788:10:788:Infinity":270,"s:791:6:791:Infinity":271,"b:795:4:815:Infinity:undefined:undefined:undefined:undefined":58,"s:795:4:815:Infinity":272,"b:795:8:795:34:795:34:795:55":59,"s:796:6:796:Infinity":273,"s:797:6:797:Infinity":274,"s:799:6:814:Infinity":275,"s:800:21:800:Infinity":276,"s:801:23:801:Infinity":277,"b:801:56:801:62:801:62:801:Infinity":60,"s:802:8:802:Infinity":278,"b:804:8:806:Infinity:undefined:undefined:undefined:undefined":61,"s:804:8:806:Infinity":279,"s:805:10:805:Infinity":280,"b:805:99:805:107:805:107:805:109":62,"b:808:8:810:Infinity:undefined:undefined:undefined:undefined":63,"s:808:8:810:Infinity":281,"s:809:10:809:Infinity":282,"s:812:8:812:Infinity":283,"s:813:8:813:Infinity":284,"b:818:4:821:Infinity:undefined:undefined:undefined:undefined":64,"s:818:4:821:Infinity":285,"b:818:8:818:37:818:37:818:62:818:62:818:84":65,"s:819:6:819:Infinity":286,"s:820:6:820:Infinity":287,"s:824:21:824:Infinity":288,"s:825:26:825:Infinity":289,"b:825:26:825:46:825:46:825:Infinity":66,"b:826:4:832:Infinity:undefined:undefined:undefined:undefined":67,"s:826:4:832:Infinity":290,"b:826:8:826:24:826:24:826:43":68,"s:827:35:827:Infinity":291,"s:828:28:828:Infinity":292,"s:829:6:829:Infinity":293,"s:830:6:830:Infinity":294,"s:831:6:831:Infinity":295,"s:834:4:834:Infinity":296,"f:840:8:840:24":30,"s:841:23:841:Infinity":297,"s:843:28:843:Infinity":298,"b:844:4:844:Infinity:undefined:undefined:undefined:undefined":69,"s:844:4:844:Infinity":299,"s:844:28:844:Infinity":300,"b:845:4:845:Infinity:undefined:undefined:undefined:undefined":70,"s:845:4:845:Infinity":301,"s:845:30:845:Infinity":302,"b:846:4:848:Infinity:undefined:undefined:undefined:undefined":71,"s:846:4:848:Infinity":303,"s:847:6:847:Infinity":304,"b:849:4:849:Infinity:undefined:undefined:undefined:undefined":72,"s:849:4:849:Infinity":305,"s:849:30:849:Infinity":306,"s:850:4:850:Infinity":307,"b:850:11:850:31:850:31:850:Infinity":73,"f:856:8:856:34":31,"s:857:25:857:Infinity":308,"s:858:20:858:Infinity":309,"s:859:20:859:Infinity":310,"s:862:35:862:Infinity":311,"s:863:39:863:Infinity":312,"s:864:31:864:Infinity":313,"s:866:4:881:Infinity":314,"s:867:6:880:Infinity":315,"s:868:22:868:Infinity":316,"s:869:25:869:Infinity":317,"b:869:25:869:44:869:44:869:58:869:58:869:Infinity":74,"b:871:8:877:Infinity:873:8:877:Infinity":75,"s:871:8:877:Infinity":318,"b:871:12:871:35:871:35:871:45":76,"s:872:10:872:Infinity":319,"b:873:8:877:Infinity:875:8:877:Infinity":77,"s:873:8:877:Infinity":320,"b:873:19:873:43:873:43:873:53":78,"s:874:10:874:Infinity":321,"b:875:8:877:Infinity:undefined:undefined:undefined:undefined":79,"s:875:8:877:Infinity":322,"b:875:19:875:45:875:45:875:60":80,"s:876:10:876:Infinity":323,"s:884:20:886:Infinity":324,"b:885:8:885:Infinity:886:8:886:Infinity":81,"f:885:20:885:25":32,"s:885:25:885:81":325,"b:886:8:886:27:886:27:886:Infinity":82,"s:889:43:889:Infinity":326,"s:890:4:892:Infinity":327,"s:891:6:891:Infinity":328,"b:891:26:891:46:891:46:891:51":83,"s:893:37:893:Infinity":329,"b:894:4:894:Infinity:undefined:undefined:undefined:undefined":84,"s:894:4:894:Infinity":330,"s:894:22:894:Infinity":331,"b:895:4:895:Infinity:undefined:undefined:undefined:undefined":85,"s:895:4:895:Infinity":332,"s:895:21:895:Infinity":333,"b:896:4:896:Infinity:undefined:undefined:undefined:undefined":86,"s:896:4:896:Infinity":334,"s:896:24:896:Infinity":335,"b:897:4:897:Infinity:undefined:undefined:undefined:undefined":87,"s:897:4:897:Infinity":336,"s:897:23:897:Infinity":337,"s:900:18:902:Infinity":338,"b:901:8:901:Infinity:902:8:902:Infinity":88,"b:901:77:901:113:901:113:901:115":89,"s:904:4:914:Infinity":339,"b:906:15:906:35:906:35:906:Infinity":90,"b:908:17:908:46:908:46:908:Infinity":91,"f:922:8:922:27":33,"s:923:4:923:Infinity":340,"s:925:16:925:Infinity":341,"s:926:19:941:Infinity":342,"s:943:15:943:Infinity":343,"b:946:4:948:Infinity:undefined:undefined:undefined:undefined":92,"s:946:4:948:Infinity":344,"s:947:6:947:Infinity":345,"s:950:4:954:Infinity":346,"f:960:8:960:27":34,"b:960:60:960:90":93,"s:961:4:961:Infinity":347,"s:963:17:968:Infinity":348,"s:970:4:970:Infinity":349,"f:970:20:970:27":35,"s:970:27:970:49":350,"f:979:8:979:29":36,"s:980:4:980:Infinity":351,"s:983:17:985:Infinity":352,"b:987:4:987:Infinity:undefined:undefined:undefined:undefined":94,"s:987:4:987:Infinity":353,"s:987:27:987:Infinity":354,"s:989:20:989:Infinity":355,"s:992:24:992:Infinity":356,"b:993:4:993:Infinity:undefined:undefined:undefined:undefined":95,"s:993:4:993:Infinity":357,"s:993:22:993:Infinity":358,"s:996:25:1001:Infinity":359,"b:997:24:997:56:997:56:997:Infinity":96,"b:998:26:998:62:998:62:998:Infinity":97,"b:999:41:999:97:999:97:999:Infinity":98,"b:1000:22:1000:50:1000:50:1000:Infinity":99,"s:1004:21:1004:Infinity":360,"f:1004:80:1004:86":37,"s:1004:86:1004:90":361,"b:1005:4:1005:Infinity:undefined:undefined:undefined:undefined":100,"s:1005:4:1005:Infinity":362,"s:1005:19:1005:Infinity":363,"s:1008:4:1012:Infinity":364,"s:1014:4:1014:Infinity":365,"f:1017:10:1017:23":38,"s:1018:4:1030:Infinity":366,"b:1022:15:1022:40:1022:40:1022:Infinity":101,"b:1023:17:1023:44:1023:44:1023:Infinity":102,"b:1024:29:1024:58:1024:58:1024:62":103,"b:1025:33:1025:66:1025:66:1025:70":104,"b:1026:17:1026:45:1026:45:1026:Infinity":105,"b:1027:13:1027:36:1027:36:1027:Infinity":106,"b:1028:20:1028:51:1028:51:1028:Infinity":107,"f:1035:16:1035:51":39,"b:1036:4:1038:Infinity:undefined:undefined:undefined:undefined":108,"s:1036:4:1038:Infinity":367,"s:1037:6:1037:Infinity":368,"f:1041:10:1041:31":40,"b:1042:4:1042:Infinity:undefined:undefined:undefined:undefined":109,"s:1042:4:1042:Infinity":369,"s:1042:18:1042:Infinity":370,"s:1044:4:1056:Infinity":371,"s:1058:4:1080:Infinity":372,"s:1083:4:1094:Infinity":373,"s:1097:4:1113:Infinity":374,"s:1117:4:1126:Infinity":375,"s:1129:4:1129:Infinity":376,"s:1130:4:1130:Infinity":377,"s:1131:4:1131:Infinity":378,"s:1132:4:1132:Infinity":379,"s:1133:4:1133:Infinity":380,"s:1134:4:1134:Infinity":381,"s:1135:4:1135:Infinity":382,"s:1136:4:1136:Infinity":383,"s:1139:4:1139:Infinity":384,"f:1145:10:1145:32":41,"b:1146:4:1146:Infinity:undefined:undefined:undefined:undefined":110,"s:1146:4:1146:Infinity":385,"s:1146:18:1146:Infinity":386,"s:1148:4:1177:Infinity":387,"s:1149:25:1149:Infinity":388,"s:1150:26:1150:Infinity":389,"f:1150:49:1150:54":42,"s:1150:54:1150:60":390,"s:1152:50:1160:Infinity":391,"s:1162:6:1166:Infinity":392,"b:1163:8:1165:Infinity:undefined:undefined:undefined:undefined":111,"s:1163:8:1165:Infinity":393,"s:1164:10:1164:Infinity":394,"s:1169:6:1174:Infinity":395,"s:1170:21:1170:Infinity":396,"b:1171:8:1173:Infinity:undefined:undefined:undefined:undefined":112,"s:1171:8:1173:Infinity":397,"f:1171:23:1171:28":43,"s:1171:28:1171:50":398,"s:1172:10:1172:Infinity":399,"f:1180:10:1180:23":44,"s:1181:4:1191:Infinity":400,"f:1194:10:1194:27":45,"s:1195:4:1213:Infinity":401,"b:1207:29:1207:58:1207:58:1207:62":113,"b:1208:33:1208:66:1208:66:1208:70":114,"b:1211:25:1211:49:1211:49:1211:53":115,"b:1212:28:1212:55:1212:55:1212:59":116,"f:1216:10:1216:29":46,"s:1217:16:1217:Infinity":402,"s:1218:17:1218:Infinity":403,"s:1220:20:1220:Infinity":404,"s:1221:18:1221:Infinity":405,"s:1222:17:1222:Infinity":406,"b:1224:4:1224:Infinity:undefined:undefined:undefined:undefined":117,"s:1224:4:1224:Infinity":407,"s:1224:21:1224:Infinity":408,"b:1225:4:1225:Infinity:undefined:undefined:undefined:undefined":118,"s:1225:4:1225:Infinity":409,"s:1225:22:1225:Infinity":410,"b:1226:4:1226:Infinity:undefined:undefined:undefined:undefined":119,"s:1226:4:1226:Infinity":411,"s:1226:20:1226:Infinity":412,"b:1227:4:1227:Infinity:undefined:undefined:undefined:undefined":120,"s:1227:4:1227:Infinity":413,"s:1227:18:1227:Infinity":414,"s:1229:4:1229:Infinity":415,"f:1232:10:1232:29":47,"b:1234:6:1234:Infinity:1235:6:1235:Infinity:1236:6:1236:Infinity:1237:6:1237:Infinity:1238:6:1238:Infinity":121,"s:1233:4:1239:Infinity":416,"s:1234:19:1234:Infinity":417,"s:1235:20:1235:Infinity":418,"s:1236:22:1236:Infinity":419,"s:1237:21:1237:Infinity":420,"s:1238:15:1238:Infinity":421,"f:1246:16:1246:34":48,"s:1247:2:1247:Infinity":422,"f:1255:16:1255:44":49,"b:1256:2:1256:Infinity:undefined:undefined:undefined:undefined":122,"s:1256:2:1256:Infinity":423,"b:1256:6:1256:25:1256:25:1256:54":123,"s:1256:54:1256:Infinity":424,"s:1258:2:1301:Infinity":425,"s:1259:10:1259:Infinity":426,"b:1260:4:1260:Infinity:undefined:undefined:undefined:undefined":124,"s:1260:4:1260:Infinity":427,"s:1260:18:1260:Infinity":428,"s:1262:18:1262:Infinity":429,"s:1265:4:1296:Infinity":430,"s:1265:17:1265:35":431,"s:1266:6:1295:Infinity":432,"s:1267:21:1267:Infinity":433,"b:1268:8:1268:Infinity:undefined:undefined:undefined:undefined":125,"s:1268:8:1268:Infinity":434,"s:1268:39:1268:Infinity":435,"s:1270:27:1270:Infinity":436,"b:1271:8:1271:Infinity:undefined:undefined:undefined:undefined":126,"s:1271:8:1271:Infinity":437,"s:1271:25:1271:Infinity":438,"s:1273:19:1273:Infinity":439,"b:1274:8:1282:Infinity:1276:8:1282:Infinity":127,"s:1274:8:1282:Infinity":440,"s:1275:10:1275:Infinity":441,"b:1276:8:1282:Infinity:undefined:undefined:undefined:undefined":128,"s:1276:8:1282:Infinity":442,"s:1278:10:1281:Infinity":443,"f:1279:20:1279:21":50,"s:1279:45:1279:62":444,"f:1280:17:1280:18":51,"s:1280:42:1280:48":445,"b:1284:8:1284:Infinity:undefined:undefined:undefined:undefined":129,"s:1284:8:1284:Infinity":446,"s:1284:19:1284:Infinity":447,"s:1287:8:1287:Infinity":448,"s:1288:8:1288:Infinity":449,"s:1291:8:1291:Infinity":450,"s:1294:8:1294:Infinity":451,"s:1298:4:1298:Infinity":452,"s:1300:4:1300:Infinity":453}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/session-init.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/session-init.ts","statementMap":{"0":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"1":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"2":{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},"3":{"start":{"line":37,"column":6},"end":{"line":37,"column":null}},"4":{"start":{"line":45,"column":4},"end":{"line":78,"column":null}},"5":{"start":{"line":47,"column":6},"end":{"line":47,"column":null}},"6":{"start":{"line":50,"column":6},"end":{"line":54,"column":null}},"7":{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},"8":{"start":{"line":58,"column":8},"end":{"line":62,"column":null}},"9":{"start":{"line":65,"column":6},"end":{"line":68,"column":null}},"10":{"start":{"line":71,"column":6},"end":{"line":71,"column":null}},"11":{"start":{"line":73,"column":6},"end":{"line":77,"column":null}},"12":{"start":{"line":86,"column":18},"end":{"line":86,"column":null}},"13":{"start":{"line":87,"column":2},"end":{"line":87,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":27,"column":2},"end":{"line":27,"column":14}},"loc":{"start":{"line":27,"column":63},"end":{"line":30,"column":null}},"line":27},"1":{"name":"(anonymous_1)","decl":{"start":{"line":35,"column":8},"end":{"line":35,"column":34}},"loc":{"start":{"line":35,"column":34},"end":{"line":39,"column":null}},"line":35},"2":{"name":"(anonymous_2)","decl":{"start":{"line":44,"column":8},"end":{"line":44,"column":16}},"loc":{"start":{"line":44,"column":65},"end":{"line":79,"column":null}},"line":44},"3":{"name":"createSessionInitHook","decl":{"start":{"line":85,"column":16},"end":{"line":85,"column":38}},"loc":{"start":{"line":85,"column":68},"end":{"line":88,"column":null}},"line":85}},"branchMap":{"0":{"loc":{"start":{"line":27,"column":42},"end":{"line":27,"column":63}},"type":"default-arg","locations":[{"start":{"line":27,"column":56},"end":{"line":27,"column":63}}],"line":27},"1":{"loc":{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},"type":"if","locations":[{"start":{"line":36,"column":4},"end":{"line":38,"column":null}},{"start":{},"end":{}}],"line":36},"2":{"loc":{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":6},"end":{"line":63,"column":null}},{"start":{},"end":{}}],"line":57},"3":{"loc":{"start":{"line":76,"column":15},"end":{"line":76,"column":null}},"type":"cond-expr","locations":[{"start":{"line":76,"column":40},"end":{"line":76,"column":56}},{"start":{"line":76,"column":56},"end":{"line":76,"column":null}}],"line":76}},"s":{"0":19,"1":19,"2":36,"3":36,"4":19,"5":19,"6":18,"7":18,"8":11,"9":18,"10":1,"11":1,"12":18,"13":18},"f":{"0":19,"1":36,"2":19,"3":18},"b":{"0":[19],"1":[36,0],"2":[11,7],"3":[1,0]},"meta":{"lastBranch":4,"lastFunction":4,"lastStatement":14,"seen":{"f:27:2:27:14":0,"b:27:56:27:63":0,"s:28:4:28:Infinity":0,"s:29:4:29:Infinity":1,"f:35:8:35:34":1,"b:36:4:38:Infinity:undefined:undefined:undefined:undefined":1,"s:36:4:38:Infinity":2,"s:37:6:37:Infinity":3,"f:44:8:44:16":2,"s:45:4:78:Infinity":4,"s:47:6:47:Infinity":5,"s:50:6:54:Infinity":6,"b:57:6:63:Infinity:undefined:undefined:undefined:undefined":2,"s:57:6:63:Infinity":7,"s:58:8:62:Infinity":8,"s:65:6:68:Infinity":9,"s:71:6:71:Infinity":10,"s:73:6:77:Infinity":11,"b:76:40:76:56:76:56:76:Infinity":3,"f:85:16:85:38":3,"s:86:18:86:Infinity":12,"s:87:2:87:Infinity":13}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/summarize.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/summarize.ts","statementMap":{"0":{"start":{"line":31,"column":4},"end":{"line":31,"column":null}},"1":{"start":{"line":32,"column":4},"end":{"line":32,"column":null}},"2":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"3":{"start":{"line":40,"column":6},"end":{"line":40,"column":null}},"4":{"start":{"line":48,"column":4},"end":{"line":118,"column":null}},"5":{"start":{"line":50,"column":6},"end":{"line":50,"column":null}},"6":{"start":{"line":53,"column":22},"end":{"line":53,"column":null}},"7":{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},"8":{"start":{"line":56,"column":8},"end":{"line":59,"column":null}},"9":{"start":{"line":63,"column":25},"end":{"line":63,"column":null}},"10":{"start":{"line":66,"column":6},"end":{"line":66,"column":null}},"11":{"start":{"line":69,"column":26},"end":{"line":69,"column":null}},"12":{"start":{"line":70,"column":6},"end":{"line":70,"column":null}},"13":{"start":{"line":73,"column":6},"end":{"line":73,"column":null}},"14":{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},"15":{"start":{"line":75,"column":8},"end":{"line":75,"column":null}},"16":{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},"17":{"start":{"line":80,"column":8},"end":{"line":92,"column":null}},"18":{"start":{"line":81,"column":26},"end":{"line":81,"column":null}},"19":{"start":{"line":82,"column":16},"end":{"line":88,"column":null}},"20":{"start":{"line":89,"column":10},"end":{"line":89,"column":null}},"21":{"start":{"line":96,"column":6},"end":{"line":96,"column":null}},"22":{"start":{"line":98,"column":6},"end":{"line":101,"column":null}},"23":{"start":{"line":104,"column":6},"end":{"line":104,"column":null}},"24":{"start":{"line":107,"column":6},"end":{"line":111,"column":null}},"25":{"start":{"line":108,"column":8},"end":{"line":108,"column":null}},"26":{"start":{"line":113,"column":6},"end":{"line":117,"column":null}},"27":{"start":{"line":126,"column":18},"end":{"line":126,"column":null}},"28":{"start":{"line":127,"column":2},"end":{"line":127,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":30,"column":2},"end":{"line":30,"column":14}},"loc":{"start":{"line":30,"column":63},"end":{"line":33,"column":null}},"line":30},"1":{"name":"(anonymous_1)","decl":{"start":{"line":38,"column":8},"end":{"line":38,"column":34}},"loc":{"start":{"line":38,"column":34},"end":{"line":42,"column":null}},"line":38},"2":{"name":"(anonymous_2)","decl":{"start":{"line":47,"column":8},"end":{"line":47,"column":16}},"loc":{"start":{"line":47,"column":65},"end":{"line":119,"column":null}},"line":47},"3":{"name":"createSummarizeHook","decl":{"start":{"line":125,"column":16},"end":{"line":125,"column":36}},"loc":{"start":{"line":125,"column":64},"end":{"line":128,"column":null}},"line":125}},"branchMap":{"0":{"loc":{"start":{"line":30,"column":42},"end":{"line":30,"column":63}},"type":"default-arg","locations":[{"start":{"line":30,"column":56},"end":{"line":30,"column":63}}],"line":30},"1":{"loc":{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},"type":"if","locations":[{"start":{"line":39,"column":4},"end":{"line":41,"column":null}},{"start":{},"end":{}}],"line":39},"2":{"loc":{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":60,"column":null}},{"start":{},"end":{}}],"line":54},"3":{"loc":{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},"type":"if","locations":[{"start":{"line":74,"column":6},"end":{"line":76,"column":null}},{"start":{},"end":{}}],"line":74},"4":{"loc":{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},"type":"if","locations":[{"start":{"line":79,"column":6},"end":{"line":93,"column":null}},{"start":{},"end":{}}],"line":79},"5":{"loc":{"start":{"line":79,"column":6},"end":{"line":79,"column":59}},"type":"binary-expr","locations":[{"start":{"line":79,"column":6},"end":{"line":79,"column":37}},{"start":{"line":79,"column":37},"end":{"line":79,"column":59}}],"line":79},"6":{"loc":{"start":{"line":116,"column":15},"end":{"line":116,"column":null}},"type":"cond-expr","locations":[{"start":{"line":116,"column":40},"end":{"line":116,"column":56}},{"start":{"line":116,"column":56},"end":{"line":116,"column":null}}],"line":116}},"s":{"0":5,"1":5,"2":4,"3":4,"4":5,"5":5,"6":4,"7":4,"8":1,"9":3,"10":3,"11":3,"12":3,"13":3,"14":3,"15":0,"16":3,"17":0,"18":0,"19":0,"20":0,"21":3,"22":3,"23":1,"24":1,"25":1,"26":1,"27":4,"28":4},"f":{"0":5,"1":4,"2":5,"3":4},"b":{"0":[5],"1":[4,0],"2":[1,3],"3":[0,3],"4":[0,3],"5":[3,0],"6":[1,0]},"meta":{"lastBranch":7,"lastFunction":4,"lastStatement":29,"seen":{"f:30:2:30:14":0,"b:30:56:30:63":0,"s:31:4:31:Infinity":0,"s:32:4:32:Infinity":1,"f:38:8:38:34":1,"b:39:4:41:Infinity:undefined:undefined:undefined:undefined":1,"s:39:4:41:Infinity":2,"s:40:6:40:Infinity":3,"f:47:8:47:16":2,"s:48:4:118:Infinity":4,"s:50:6:50:Infinity":5,"s:53:22:53:Infinity":6,"b:54:6:60:Infinity:undefined:undefined:undefined:undefined":2,"s:54:6:60:Infinity":7,"s:56:8:59:Infinity":8,"s:63:25:63:Infinity":9,"s:66:6:66:Infinity":10,"s:69:26:69:Infinity":11,"s:70:6:70:Infinity":12,"s:73:6:73:Infinity":13,"b:74:6:76:Infinity:undefined:undefined:undefined:undefined":3,"s:74:6:76:Infinity":14,"s:75:8:75:Infinity":15,"b:79:6:93:Infinity:undefined:undefined:undefined:undefined":4,"s:79:6:93:Infinity":16,"b:79:6:79:37:79:37:79:59":5,"s:80:8:92:Infinity":17,"s:81:26:81:Infinity":18,"s:82:16:88:Infinity":19,"s:89:10:89:Infinity":20,"s:96:6:96:Infinity":21,"s:98:6:101:Infinity":22,"s:104:6:104:Infinity":23,"s:107:6:111:Infinity":24,"s:108:8:108:Infinity":25,"s:113:6:117:Infinity":26,"b:116:40:116:56:116:56:116:Infinity":6,"f:125:16:125:36":3,"s:126:18:126:Infinity":27,"s:127:2:127:Infinity":28}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/types.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/types.ts","statementMap":{"0":{"start":{"line":323,"column":20},"end":{"line":323,"column":null}},"1":{"start":{"line":324,"column":17},"end":{"line":324,"column":null}},"2":{"start":{"line":325,"column":2},"end":{"line":325,"column":null}},"3":{"start":{"line":332,"column":16},"end":{"line":332,"column":null}},"4":{"start":{"line":333,"column":2},"end":{"line":333,"column":null}},"5":{"start":{"line":340,"column":20},"end":{"line":340,"column":null}},"6":{"start":{"line":341,"column":21},"end":{"line":341,"column":null}},"7":{"start":{"line":342,"column":23},"end":{"line":342,"column":null}},"8":{"start":{"line":343,"column":22},"end":{"line":343,"column":null}},"9":{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},"10":{"start":{"line":345,"column":36},"end":{"line":345,"column":null}},"11":{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},"12":{"start":{"line":346,"column":37},"end":{"line":346,"column":null}},"13":{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},"14":{"start":{"line":347,"column":39},"end":{"line":347,"column":null}},"15":{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},"16":{"start":{"line":348,"column":38},"end":{"line":348,"column":null}},"17":{"start":{"line":349,"column":2},"end":{"line":349,"column":null}},"18":{"start":{"line":356,"column":30},"end":{"line":356,"column":null}},"19":{"start":{"line":357,"column":34},"end":{"line":357,"column":null}},"20":{"start":{"line":359,"column":2},"end":{"line":373,"column":null}},"21":{"start":{"line":360,"column":18},"end":{"line":360,"column":null}},"22":{"start":{"line":361,"column":21},"end":{"line":361,"column":null}},"23":{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},"24":{"start":{"line":363,"column":19},"end":{"line":363,"column":null}},"25":{"start":{"line":365,"column":17},"end":{"line":365,"column":null}},"26":{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},"27":{"start":{"line":367,"column":6},"end":{"line":367,"column":null}},"28":{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},"29":{"start":{"line":369,"column":6},"end":{"line":369,"column":null}},"30":{"start":{"line":375,"column":2},"end":{"line":375,"column":null}},"31":{"start":{"line":382,"column":2},"end":{"line":411,"column":null}},"32":{"start":{"line":383,"column":18},"end":{"line":383,"column":null}},"33":{"start":{"line":385,"column":4},"end":{"line":408,"column":null}},"34":{"start":{"line":387,"column":8},"end":{"line":387,"column":null}},"35":{"start":{"line":389,"column":8},"end":{"line":389,"column":null}},"36":{"start":{"line":392,"column":8},"end":{"line":392,"column":null}},"37":{"start":{"line":394,"column":20},"end":{"line":394,"column":null}},"38":{"start":{"line":395,"column":8},"end":{"line":395,"column":null}},"39":{"start":{"line":397,"column":8},"end":{"line":397,"column":null}},"40":{"start":{"line":399,"column":8},"end":{"line":399,"column":null}},"41":{"start":{"line":401,"column":8},"end":{"line":401,"column":null}},"42":{"start":{"line":403,"column":8},"end":{"line":403,"column":null}},"43":{"start":{"line":405,"column":8},"end":{"line":405,"column":null}},"44":{"start":{"line":407,"column":8},"end":{"line":407,"column":null}},"45":{"start":{"line":410,"column":4},"end":{"line":410,"column":null}},"46":{"start":{"line":418,"column":2},"end":{"line":456,"column":null}},"47":{"start":{"line":419,"column":18},"end":{"line":419,"column":null}},"48":{"start":{"line":420,"column":21},"end":{"line":420,"column":null}},"49":{"start":{"line":421,"column":21},"end":{"line":421,"column":null}},"50":{"start":{"line":423,"column":4},"end":{"line":453,"column":null}},"51":{"start":{"line":425,"column":8},"end":{"line":425,"column":null}},"52":{"start":{"line":427,"column":8},"end":{"line":427,"column":null}},"53":{"start":{"line":430,"column":8},"end":{"line":430,"column":null}},"54":{"start":{"line":432,"column":14},"end":{"line":432,"column":null}},"55":{"start":{"line":433,"column":47},"end":{"line":438,"column":null}},"56":{"start":{"line":439,"column":8},"end":{"line":439,"column":null}},"57":{"start":{"line":442,"column":8},"end":{"line":442,"column":null}},"58":{"start":{"line":444,"column":8},"end":{"line":444,"column":null}},"59":{"start":{"line":446,"column":8},"end":{"line":446,"column":null}},"60":{"start":{"line":448,"column":8},"end":{"line":448,"column":null}},"61":{"start":{"line":450,"column":8},"end":{"line":450,"column":null}},"62":{"start":{"line":452,"column":8},"end":{"line":452,"column":null}},"63":{"start":{"line":455,"column":4},"end":{"line":455,"column":null}},"64":{"start":{"line":465,"column":2},"end":{"line":504,"column":null}},"65":{"start":{"line":466,"column":18},"end":{"line":466,"column":null}},"66":{"start":{"line":467,"column":21},"end":{"line":467,"column":null}},"67":{"start":{"line":469,"column":4},"end":{"line":501,"column":null}},"68":{"start":{"line":471,"column":8},"end":{"line":471,"column":null}},"69":{"start":{"line":473,"column":8},"end":{"line":473,"column":null}},"70":{"start":{"line":476,"column":23},"end":{"line":476,"column":null}},"71":{"start":{"line":477,"column":8},"end":{"line":477,"column":null}},"72":{"start":{"line":480,"column":20},"end":{"line":480,"column":null}},"73":{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},"74":{"start":{"line":482,"column":10},"end":{"line":482,"column":null}},"75":{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},"76":{"start":{"line":484,"column":10},"end":{"line":484,"column":null}},"77":{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},"78":{"start":{"line":486,"column":10},"end":{"line":486,"column":null}},"79":{"start":{"line":487,"column":8},"end":{"line":487,"column":null}},"80":{"start":{"line":490,"column":8},"end":{"line":490,"column":null}},"81":{"start":{"line":492,"column":8},"end":{"line":492,"column":null}},"82":{"start":{"line":494,"column":8},"end":{"line":494,"column":null}},"83":{"start":{"line":496,"column":8},"end":{"line":496,"column":null}},"84":{"start":{"line":498,"column":8},"end":{"line":498,"column":null}},"85":{"start":{"line":500,"column":8},"end":{"line":500,"column":null}},"86":{"start":{"line":503,"column":4},"end":{"line":503,"column":null}},"87":{"start":{"line":511,"column":26},"end":{"line":511,"column":null}},"88":{"start":{"line":513,"column":2},"end":{"line":562,"column":null}},"89":{"start":{"line":514,"column":18},"end":{"line":514,"column":null}},"90":{"start":{"line":515,"column":21},"end":{"line":515,"column":null}},"91":{"start":{"line":516,"column":21},"end":{"line":516,"column":null}},"92":{"start":{"line":518,"column":4},"end":{"line":559,"column":null}},"93":{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},"94":{"start":{"line":520,"column":22},"end":{"line":520,"column":null}},"95":{"start":{"line":521,"column":8},"end":{"line":521,"column":null}},"96":{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},"97":{"start":{"line":523,"column":22},"end":{"line":523,"column":null}},"98":{"start":{"line":524,"column":8},"end":{"line":524,"column":null}},"99":{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},"100":{"start":{"line":527,"column":22},"end":{"line":527,"column":null}},"101":{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},"102":{"start":{"line":528,"column":31},"end":{"line":528,"column":null}},"103":{"start":{"line":529,"column":8},"end":{"line":529,"column":null}},"104":{"start":{"line":531,"column":20},"end":{"line":531,"column":null}},"105":{"start":{"line":532,"column":8},"end":{"line":532,"column":null}},"106":{"start":{"line":534,"column":23},"end":{"line":534,"column":null}},"107":{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},"108":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"109":{"start":{"line":536,"column":65},"end":{"line":536,"column":null}},"110":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"111":{"start":{"line":537,"column":65},"end":{"line":537,"column":null}},"112":{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},"113":{"start":{"line":538,"column":68},"end":{"line":538,"column":null}},"114":{"start":{"line":540,"column":8},"end":{"line":540,"column":null}},"115":{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},"116":{"start":{"line":543,"column":28},"end":{"line":543,"column":null}},"117":{"start":{"line":544,"column":8},"end":{"line":544,"column":null}},"118":{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},"119":{"start":{"line":546,"column":28},"end":{"line":546,"column":null}},"120":{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},"121":{"start":{"line":547,"column":25},"end":{"line":547,"column":null}},"122":{"start":{"line":548,"column":8},"end":{"line":548,"column":null}},"123":{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},"124":{"start":{"line":550,"column":26},"end":{"line":550,"column":null}},"125":{"start":{"line":551,"column":8},"end":{"line":551,"column":null}},"126":{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},"127":{"start":{"line":553,"column":24},"end":{"line":553,"column":null}},"128":{"start":{"line":554,"column":8},"end":{"line":554,"column":null}},"129":{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},"130":{"start":{"line":556,"column":32},"end":{"line":556,"column":null}},"131":{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},"132":{"start":{"line":557,"column":34},"end":{"line":557,"column":null}},"133":{"start":{"line":558,"column":8},"end":{"line":558,"column":null}},"134":{"start":{"line":564,"column":2},"end":{"line":564,"column":null}},"135":{"start":{"line":571,"column":32},"end":{"line":571,"column":null}},"136":{"start":{"line":573,"column":2},"end":{"line":631,"column":null}},"137":{"start":{"line":574,"column":18},"end":{"line":574,"column":null}},"138":{"start":{"line":575,"column":22},"end":{"line":575,"column":null}},"139":{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},"140":{"start":{"line":580,"column":20},"end":{"line":580,"column":null}},"141":{"start":{"line":581,"column":6},"end":{"line":603,"column":null}},"142":{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},"143":{"start":{"line":582,"column":78},"end":{"line":582,"column":null}},"144":{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},"145":{"start":{"line":585,"column":22},"end":{"line":585,"column":null}},"146":{"start":{"line":586,"column":49},"end":{"line":591,"column":null}},"147":{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},"148":{"start":{"line":592,"column":34},"end":{"line":592,"column":null}},"149":{"start":{"line":595,"column":47},"end":{"line":601,"column":null}},"150":{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},"151":{"start":{"line":602,"column":26},"end":{"line":602,"column":null}},"152":{"start":{"line":607,"column":4},"end":{"line":628,"column":null}},"153":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"154":{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},"155":{"start":{"line":610,"column":84},"end":{"line":610,"column":null}},"156":{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},"157":{"start":{"line":611,"column":58},"end":{"line":611,"column":null}},"158":{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},"159":{"start":{"line":612,"column":33},"end":{"line":612,"column":null}},"160":{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},"161":{"start":{"line":613,"column":81},"end":{"line":613,"column":null}},"162":{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},"163":{"start":{"line":614,"column":36},"end":{"line":614,"column":null}},"164":{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},"165":{"start":{"line":615,"column":60},"end":{"line":615,"column":null}},"166":{"start":{"line":616,"column":8},"end":{"line":616,"column":null}},"167":{"start":{"line":619,"column":8},"end":{"line":619,"column":null}},"168":{"start":{"line":620,"column":8},"end":{"line":620,"column":null}},"169":{"start":{"line":622,"column":8},"end":{"line":622,"column":null}},"170":{"start":{"line":623,"column":8},"end":{"line":623,"column":null}},"171":{"start":{"line":625,"column":8},"end":{"line":625,"column":null}},"172":{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},"173":{"start":{"line":626,"column":34},"end":{"line":626,"column":null}},"174":{"start":{"line":627,"column":8},"end":{"line":627,"column":null}},"175":{"start":{"line":633,"column":2},"end":{"line":633,"column":null}},"176":{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},"177":{"start":{"line":640,"column":31},"end":{"line":640,"column":null}},"178":{"start":{"line":641,"column":2},"end":{"line":641,"column":null}},"179":{"start":{"line":647,"column":57},"end":{"line":650,"column":null}},"180":{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},"181":{"start":{"line":657,"column":4},"end":{"line":662,"column":null}},"182":{"start":{"line":665,"column":2},"end":{"line":665,"column":null}},"183":{"start":{"line":672,"column":2},"end":{"line":698,"column":null}},"184":{"start":{"line":673,"column":37},"end":{"line":673,"column":null}},"185":{"start":{"line":675,"column":16},"end":{"line":675,"column":null}},"186":{"start":{"line":677,"column":4},"end":{"line":688,"column":null}},"187":{"start":{"line":691,"column":16},"end":{"line":691,"column":null}},"188":{"start":{"line":692,"column":4},"end":{"line":697,"column":null}}},"fnMap":{"0":{"name":"generateObservationId","decl":{"start":{"line":322,"column":16},"end":{"line":322,"column":48}},"loc":{"start":{"line":322,"column":48},"end":{"line":326,"column":null}},"line":322},"1":{"name":"getProjectName","decl":{"start":{"line":331,"column":16},"end":{"line":331,"column":31}},"loc":{"start":{"line":331,"column":52},"end":{"line":334,"column":null}},"line":331},"2":{"name":"getObservationType","decl":{"start":{"line":339,"column":16},"end":{"line":339,"column":35}},"loc":{"start":{"line":339,"column":70},"end":{"line":350,"column":null}},"line":339},"3":{"name":"extractFilePaths","decl":{"start":{"line":355,"column":16},"end":{"line":355,"column":33}},"loc":{"start":{"line":355,"column":121},"end":{"line":376,"column":null}},"line":355},"4":{"name":"generateObservationTitle","decl":{"start":{"line":381,"column":16},"end":{"line":381,"column":41}},"loc":{"start":{"line":381,"column":87},"end":{"line":412,"column":null}},"line":381},"5":{"name":"generateObservationSubtitle","decl":{"start":{"line":417,"column":16},"end":{"line":417,"column":44}},"loc":{"start":{"line":417,"column":115},"end":{"line":457,"column":null}},"line":417},"6":{"name":"generateObservationNarrative","decl":{"start":{"line":462,"column":16},"end":{"line":462,"column":null}},"loc":{"start":{"line":464,"column":10},"end":{"line":505,"column":null}},"line":464},"7":{"name":"extractFacts","decl":{"start":{"line":510,"column":16},"end":{"line":510,"column":29}},"loc":{"start":{"line":510,"column":100},"end":{"line":565,"column":null}},"line":510},"8":{"name":"extractConcepts","decl":{"start":{"line":570,"column":16},"end":{"line":570,"column":32}},"loc":{"start":{"line":570,"column":105},"end":{"line":634,"column":null}},"line":570},"9":{"name":"truncate","decl":{"start":{"line":639,"column":16},"end":{"line":639,"column":25}},"loc":{"start":{"line":639,"column":72},"end":{"line":642,"column":null}},"line":639},"10":{"name":"formatResponse","decl":{"start":{"line":655,"column":16},"end":{"line":655,"column":31}},"loc":{"start":{"line":655,"column":59},"end":{"line":666,"column":null}},"line":655},"11":{"name":"parseHookInput","decl":{"start":{"line":671,"column":16},"end":{"line":671,"column":31}},"loc":{"start":{"line":671,"column":67},"end":{"line":699,"column":null}},"line":671}},"branchMap":{"0":{"loc":{"start":{"line":333,"column":9},"end":{"line":333,"column":null}},"type":"binary-expr","locations":[{"start":{"line":333,"column":9},"end":{"line":333,"column":36}},{"start":{"line":333,"column":36},"end":{"line":333,"column":null}}],"line":333},"1":{"loc":{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},"type":"if","locations":[{"start":{"line":345,"column":2},"end":{"line":345,"column":null}},{"start":{},"end":{}}],"line":345},"2":{"loc":{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},"type":"if","locations":[{"start":{"line":346,"column":2},"end":{"line":346,"column":null}},{"start":{},"end":{}}],"line":346},"3":{"loc":{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},"type":"if","locations":[{"start":{"line":347,"column":2},"end":{"line":347,"column":null}},{"start":{},"end":{}}],"line":347},"4":{"loc":{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},"type":"if","locations":[{"start":{"line":348,"column":2},"end":{"line":348,"column":null}},{"start":{},"end":{}}],"line":348},"5":{"loc":{"start":{"line":360,"column":18},"end":{"line":360,"column":null}},"type":"cond-expr","locations":[{"start":{"line":360,"column":50},"end":{"line":360,"column":74}},{"start":{"line":360,"column":74},"end":{"line":360,"column":null}}],"line":360},"6":{"loc":{"start":{"line":361,"column":21},"end":{"line":361,"column":null}},"type":"binary-expr","locations":[{"start":{"line":361,"column":21},"end":{"line":361,"column":41}},{"start":{"line":361,"column":41},"end":{"line":361,"column":56}},{"start":{"line":361,"column":56},"end":{"line":361,"column":null}}],"line":361},"7":{"loc":{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},"type":"if","locations":[{"start":{"line":363,"column":4},"end":{"line":363,"column":null}},{"start":{},"end":{}}],"line":363},"8":{"loc":{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":366,"column":4},"end":{"line":370,"column":null}},{"start":{"line":368,"column":4},"end":{"line":370,"column":null}}],"line":366},"9":{"loc":{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},"type":"if","locations":[{"start":{"line":368,"column":4},"end":{"line":370,"column":null}},{"start":{},"end":{}}],"line":368},"10":{"loc":{"start":{"line":383,"column":18},"end":{"line":383,"column":null}},"type":"cond-expr","locations":[{"start":{"line":383,"column":50},"end":{"line":383,"column":74}},{"start":{"line":383,"column":74},"end":{"line":383,"column":null}}],"line":383},"11":{"loc":{"start":{"line":385,"column":4},"end":{"line":408,"column":null}},"type":"switch","locations":[{"start":{"line":386,"column":6},"end":{"line":387,"column":null}},{"start":{"line":388,"column":6},"end":{"line":389,"column":null}},{"start":{"line":390,"column":6},"end":{"line":390,"column":null}},{"start":{"line":391,"column":6},"end":{"line":392,"column":null}},{"start":{"line":393,"column":6},"end":{"line":395,"column":null}},{"start":{"line":396,"column":6},"end":{"line":397,"column":null}},{"start":{"line":398,"column":6},"end":{"line":399,"column":null}},{"start":{"line":400,"column":6},"end":{"line":401,"column":null}},{"start":{"line":402,"column":6},"end":{"line":403,"column":null}},{"start":{"line":404,"column":6},"end":{"line":405,"column":null}},{"start":{"line":406,"column":6},"end":{"line":407,"column":null}}],"line":385},"12":{"loc":{"start":{"line":387,"column":23},"end":{"line":387,"column":64}},"type":"binary-expr","locations":[{"start":{"line":387,"column":23},"end":{"line":387,"column":43}},{"start":{"line":387,"column":43},"end":{"line":387,"column":58}},{"start":{"line":387,"column":58},"end":{"line":387,"column":64}}],"line":387},"13":{"loc":{"start":{"line":389,"column":24},"end":{"line":389,"column":65}},"type":"binary-expr","locations":[{"start":{"line":389,"column":24},"end":{"line":389,"column":44}},{"start":{"line":389,"column":44},"end":{"line":389,"column":59}},{"start":{"line":389,"column":59},"end":{"line":389,"column":65}}],"line":389},"14":{"loc":{"start":{"line":392,"column":23},"end":{"line":392,"column":64}},"type":"binary-expr","locations":[{"start":{"line":392,"column":23},"end":{"line":392,"column":43}},{"start":{"line":392,"column":43},"end":{"line":392,"column":58}},{"start":{"line":392,"column":58},"end":{"line":392,"column":64}}],"line":392},"15":{"loc":{"start":{"line":394,"column":20},"end":{"line":394,"column":null}},"type":"binary-expr","locations":[{"start":{"line":394,"column":20},"end":{"line":394,"column":38}},{"start":{"line":394,"column":38},"end":{"line":394,"column":null}}],"line":394},"16":{"loc":{"start":{"line":395,"column":46},"end":{"line":395,"column":74}},"type":"cond-expr","locations":[{"start":{"line":395,"column":64},"end":{"line":395,"column":72}},{"start":{"line":395,"column":72},"end":{"line":395,"column":74}}],"line":395},"17":{"loc":{"start":{"line":397,"column":23},"end":{"line":397,"column":48}},"type":"binary-expr","locations":[{"start":{"line":397,"column":23},"end":{"line":397,"column":41}},{"start":{"line":397,"column":41},"end":{"line":397,"column":48}}],"line":397},"18":{"loc":{"start":{"line":399,"column":26},"end":{"line":399,"column":46}},"type":"binary-expr","locations":[{"start":{"line":399,"column":26},"end":{"line":399,"column":44}},{"start":{"line":399,"column":44},"end":{"line":399,"column":46}}],"line":399},"19":{"loc":{"start":{"line":401,"column":24},"end":{"line":401,"column":53}},"type":"binary-expr","locations":[{"start":{"line":401,"column":24},"end":{"line":401,"column":46}},{"start":{"line":401,"column":46},"end":{"line":401,"column":53}}],"line":401},"20":{"loc":{"start":{"line":403,"column":26},"end":{"line":403,"column":44}},"type":"binary-expr","locations":[{"start":{"line":403,"column":26},"end":{"line":403,"column":42}},{"start":{"line":403,"column":42},"end":{"line":403,"column":44}}],"line":403},"21":{"loc":{"start":{"line":405,"column":25},"end":{"line":405,"column":41}},"type":"binary-expr","locations":[{"start":{"line":405,"column":25},"end":{"line":405,"column":39}},{"start":{"line":405,"column":39},"end":{"line":405,"column":41}}],"line":405},"22":{"loc":{"start":{"line":419,"column":18},"end":{"line":419,"column":null}},"type":"cond-expr","locations":[{"start":{"line":419,"column":50},"end":{"line":419,"column":74}},{"start":{"line":419,"column":74},"end":{"line":419,"column":null}}],"line":419},"23":{"loc":{"start":{"line":420,"column":21},"end":{"line":420,"column":null}},"type":"binary-expr","locations":[{"start":{"line":420,"column":21},"end":{"line":420,"column":41}},{"start":{"line":420,"column":41},"end":{"line":420,"column":56}},{"start":{"line":420,"column":56},"end":{"line":420,"column":null}}],"line":420},"24":{"loc":{"start":{"line":421,"column":21},"end":{"line":421,"column":null}},"type":"cond-expr","locations":[{"start":{"line":421,"column":32},"end":{"line":421,"column":64}},{"start":{"line":421,"column":64},"end":{"line":421,"column":null}}],"line":421},"25":{"loc":{"start":{"line":423,"column":4},"end":{"line":453,"column":null}},"type":"switch","locations":[{"start":{"line":424,"column":6},"end":{"line":425,"column":null}},{"start":{"line":426,"column":6},"end":{"line":427,"column":null}},{"start":{"line":428,"column":6},"end":{"line":428,"column":null}},{"start":{"line":429,"column":6},"end":{"line":430,"column":null}},{"start":{"line":431,"column":6},"end":{"line":440,"column":null}},{"start":{"line":441,"column":6},"end":{"line":442,"column":null}},{"start":{"line":443,"column":6},"end":{"line":444,"column":null}},{"start":{"line":445,"column":6},"end":{"line":446,"column":null}},{"start":{"line":447,"column":6},"end":{"line":448,"column":null}},{"start":{"line":449,"column":6},"end":{"line":450,"column":null}},{"start":{"line":451,"column":6},"end":{"line":452,"column":null}}],"line":423},"26":{"loc":{"start":{"line":425,"column":15},"end":{"line":425,"column":null}},"type":"cond-expr","locations":[{"start":{"line":425,"column":26},"end":{"line":425,"column":52}},{"start":{"line":425,"column":52},"end":{"line":425,"column":null}}],"line":425},"27":{"loc":{"start":{"line":427,"column":15},"end":{"line":427,"column":null}},"type":"cond-expr","locations":[{"start":{"line":427,"column":26},"end":{"line":427,"column":60}},{"start":{"line":427,"column":60},"end":{"line":427,"column":null}}],"line":427},"28":{"loc":{"start":{"line":430,"column":15},"end":{"line":430,"column":null}},"type":"cond-expr","locations":[{"start":{"line":430,"column":26},"end":{"line":430,"column":52}},{"start":{"line":430,"column":52},"end":{"line":430,"column":null}}],"line":430},"29":{"loc":{"start":{"line":432,"column":21},"end":{"line":432,"column":43}},"type":"binary-expr","locations":[{"start":{"line":432,"column":21},"end":{"line":432,"column":39}},{"start":{"line":432,"column":39},"end":{"line":432,"column":43}}],"line":432},"30":{"loc":{"start":{"line":439,"column":15},"end":{"line":439,"column":null}},"type":"binary-expr","locations":[{"start":{"line":439,"column":15},"end":{"line":439,"column":30}},{"start":{"line":439,"column":30},"end":{"line":439,"column":null}}],"line":439},"31":{"loc":{"start":{"line":439,"column":43},"end":{"line":439,"column":59}},"type":"binary-expr","locations":[{"start":{"line":439,"column":43},"end":{"line":439,"column":50}},{"start":{"line":439,"column":50},"end":{"line":439,"column":59}}],"line":439},"32":{"loc":{"start":{"line":442,"column":32},"end":{"line":442,"column":57}},"type":"binary-expr","locations":[{"start":{"line":442,"column":32},"end":{"line":442,"column":50}},{"start":{"line":442,"column":50},"end":{"line":442,"column":57}}],"line":442},"33":{"loc":{"start":{"line":444,"column":38},"end":{"line":444,"column":65}},"type":"binary-expr","locations":[{"start":{"line":444,"column":38},"end":{"line":444,"column":56}},{"start":{"line":444,"column":56},"end":{"line":444,"column":65}}],"line":444},"34":{"loc":{"start":{"line":446,"column":32},"end":{"line":446,"column":67}},"type":"binary-expr","locations":[{"start":{"line":446,"column":32},"end":{"line":446,"column":56}},{"start":{"line":446,"column":56},"end":{"line":446,"column":67}}],"line":446},"35":{"loc":{"start":{"line":448,"column":32},"end":{"line":448,"column":52}},"type":"binary-expr","locations":[{"start":{"line":448,"column":32},"end":{"line":448,"column":48}},{"start":{"line":448,"column":48},"end":{"line":448,"column":52}}],"line":448},"36":{"loc":{"start":{"line":466,"column":18},"end":{"line":466,"column":null}},"type":"cond-expr","locations":[{"start":{"line":466,"column":50},"end":{"line":466,"column":74}},{"start":{"line":466,"column":74},"end":{"line":466,"column":null}}],"line":466},"37":{"loc":{"start":{"line":467,"column":21},"end":{"line":467,"column":null}},"type":"binary-expr","locations":[{"start":{"line":467,"column":21},"end":{"line":467,"column":41}},{"start":{"line":467,"column":41},"end":{"line":467,"column":56}},{"start":{"line":467,"column":56},"end":{"line":467,"column":null}}],"line":467},"38":{"loc":{"start":{"line":469,"column":4},"end":{"line":501,"column":null}},"type":"switch","locations":[{"start":{"line":470,"column":6},"end":{"line":471,"column":null}},{"start":{"line":472,"column":6},"end":{"line":473,"column":null}},{"start":{"line":474,"column":6},"end":{"line":474,"column":null}},{"start":{"line":475,"column":6},"end":{"line":478,"column":null}},{"start":{"line":479,"column":6},"end":{"line":488,"column":null}},{"start":{"line":489,"column":6},"end":{"line":490,"column":null}},{"start":{"line":491,"column":6},"end":{"line":492,"column":null}},{"start":{"line":493,"column":6},"end":{"line":494,"column":null}},{"start":{"line":495,"column":6},"end":{"line":496,"column":null}},{"start":{"line":497,"column":6},"end":{"line":498,"column":null}},{"start":{"line":499,"column":6},"end":{"line":500,"column":null}}],"line":469},"39":{"loc":{"start":{"line":471,"column":39},"end":{"line":471,"column":59}},"type":"binary-expr","locations":[{"start":{"line":471,"column":39},"end":{"line":471,"column":51}},{"start":{"line":471,"column":51},"end":{"line":471,"column":59}}],"line":471},"40":{"loc":{"start":{"line":473,"column":24},"end":{"line":473,"column":44}},"type":"binary-expr","locations":[{"start":{"line":473,"column":24},"end":{"line":473,"column":36}},{"start":{"line":473,"column":36},"end":{"line":473,"column":44}}],"line":473},"41":{"loc":{"start":{"line":476,"column":23},"end":{"line":476,"column":null}},"type":"cond-expr","locations":[{"start":{"line":476,"column":43},"end":{"line":476,"column":89}},{"start":{"line":476,"column":89},"end":{"line":476,"column":null}}],"line":476},"42":{"loc":{"start":{"line":477,"column":25},"end":{"line":477,"column":45}},"type":"binary-expr","locations":[{"start":{"line":477,"column":25},"end":{"line":477,"column":37}},{"start":{"line":477,"column":37},"end":{"line":477,"column":45}}],"line":477},"43":{"loc":{"start":{"line":480,"column":20},"end":{"line":480,"column":null}},"type":"binary-expr","locations":[{"start":{"line":480,"column":20},"end":{"line":480,"column":38}},{"start":{"line":480,"column":38},"end":{"line":480,"column":null}}],"line":480},"44":{"loc":{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},"type":"if","locations":[{"start":{"line":481,"column":8},"end":{"line":482,"column":null}},{"start":{},"end":{}}],"line":481},"45":{"loc":{"start":{"line":481,"column":12},"end":{"line":481,"column":null}},"type":"binary-expr","locations":[{"start":{"line":481,"column":12},"end":{"line":481,"column":42}},{"start":{"line":481,"column":42},"end":{"line":481,"column":null}}],"line":481},"46":{"loc":{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},"type":"if","locations":[{"start":{"line":483,"column":8},"end":{"line":484,"column":null}},{"start":{},"end":{}}],"line":483},"47":{"loc":{"start":{"line":483,"column":12},"end":{"line":483,"column":null}},"type":"binary-expr","locations":[{"start":{"line":483,"column":12},"end":{"line":483,"column":47}},{"start":{"line":483,"column":47},"end":{"line":483,"column":null}}],"line":483},"48":{"loc":{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},"type":"if","locations":[{"start":{"line":485,"column":8},"end":{"line":486,"column":null}},{"start":{},"end":{}}],"line":485},"49":{"loc":{"start":{"line":490,"column":70},"end":{"line":490,"column":90}},"type":"binary-expr","locations":[{"start":{"line":490,"column":70},"end":{"line":490,"column":88}},{"start":{"line":490,"column":88},"end":{"line":490,"column":90}}],"line":490},"50":{"loc":{"start":{"line":492,"column":45},"end":{"line":492,"column":65}},"type":"binary-expr","locations":[{"start":{"line":492,"column":45},"end":{"line":492,"column":63}},{"start":{"line":492,"column":63},"end":{"line":492,"column":65}}],"line":492},"51":{"loc":{"start":{"line":492,"column":69},"end":{"line":492,"column":107}},"type":"cond-expr","locations":[{"start":{"line":492,"column":83},"end":{"line":492,"column":105}},{"start":{"line":492,"column":105},"end":{"line":492,"column":107}}],"line":492},"52":{"loc":{"start":{"line":494,"column":38},"end":{"line":494,"column":67}},"type":"binary-expr","locations":[{"start":{"line":494,"column":38},"end":{"line":494,"column":62}},{"start":{"line":494,"column":62},"end":{"line":494,"column":67}}],"line":494},"53":{"loc":{"start":{"line":494,"column":78},"end":{"line":494,"column":106}},"type":"binary-expr","locations":[{"start":{"line":494,"column":78},"end":{"line":494,"column":100}},{"start":{"line":494,"column":100},"end":{"line":494,"column":106}}],"line":494},"54":{"loc":{"start":{"line":496,"column":40},"end":{"line":496,"column":69}},"type":"binary-expr","locations":[{"start":{"line":496,"column":40},"end":{"line":496,"column":56}},{"start":{"line":496,"column":56},"end":{"line":496,"column":69}}],"line":496},"55":{"loc":{"start":{"line":498,"column":39},"end":{"line":498,"column":60}},"type":"binary-expr","locations":[{"start":{"line":498,"column":39},"end":{"line":498,"column":53}},{"start":{"line":498,"column":53},"end":{"line":498,"column":60}}],"line":498},"56":{"loc":{"start":{"line":514,"column":18},"end":{"line":514,"column":null}},"type":"cond-expr","locations":[{"start":{"line":514,"column":50},"end":{"line":514,"column":74}},{"start":{"line":514,"column":74},"end":{"line":514,"column":null}}],"line":514},"57":{"loc":{"start":{"line":515,"column":21},"end":{"line":515,"column":null}},"type":"cond-expr","locations":[{"start":{"line":515,"column":56},"end":{"line":515,"column":83}},{"start":{"line":515,"column":83},"end":{"line":515,"column":null}}],"line":515},"58":{"loc":{"start":{"line":516,"column":21},"end":{"line":516,"column":null}},"type":"binary-expr","locations":[{"start":{"line":516,"column":21},"end":{"line":516,"column":41}},{"start":{"line":516,"column":41},"end":{"line":516,"column":56}},{"start":{"line":516,"column":56},"end":{"line":516,"column":null}}],"line":516},"59":{"loc":{"start":{"line":518,"column":4},"end":{"line":559,"column":null}},"type":"switch","locations":[{"start":{"line":519,"column":6},"end":{"line":521,"column":null}},{"start":{"line":522,"column":6},"end":{"line":524,"column":null}},{"start":{"line":525,"column":6},"end":{"line":525,"column":null}},{"start":{"line":526,"column":6},"end":{"line":529,"column":null}},{"start":{"line":530,"column":6},"end":{"line":541,"column":null}},{"start":{"line":542,"column":6},"end":{"line":544,"column":null}},{"start":{"line":545,"column":6},"end":{"line":548,"column":null}},{"start":{"line":549,"column":6},"end":{"line":551,"column":null}},{"start":{"line":552,"column":6},"end":{"line":554,"column":null}},{"start":{"line":555,"column":6},"end":{"line":558,"column":null}}],"line":518},"60":{"loc":{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},"type":"if","locations":[{"start":{"line":520,"column":8},"end":{"line":520,"column":null}},{"start":{},"end":{}}],"line":520},"61":{"loc":{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},"type":"if","locations":[{"start":{"line":523,"column":8},"end":{"line":523,"column":null}},{"start":{},"end":{}}],"line":523},"62":{"loc":{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},"type":"if","locations":[{"start":{"line":527,"column":8},"end":{"line":527,"column":null}},{"start":{},"end":{}}],"line":527},"63":{"loc":{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},"type":"if","locations":[{"start":{"line":528,"column":8},"end":{"line":528,"column":null}},{"start":{},"end":{}}],"line":528},"64":{"loc":{"start":{"line":528,"column":62},"end":{"line":528,"column":101}},"type":"binary-expr","locations":[{"start":{"line":528,"column":62},"end":{"line":528,"column":95}},{"start":{"line":528,"column":95},"end":{"line":528,"column":101}}],"line":528},"65":{"loc":{"start":{"line":531,"column":20},"end":{"line":531,"column":null}},"type":"binary-expr","locations":[{"start":{"line":531,"column":20},"end":{"line":531,"column":38}},{"start":{"line":531,"column":38},"end":{"line":531,"column":null}}],"line":531},"66":{"loc":{"start":{"line":534,"column":23},"end":{"line":534,"column":null}},"type":"binary-expr","locations":[{"start":{"line":534,"column":23},"end":{"line":534,"column":43}},{"start":{"line":534,"column":43},"end":{"line":534,"column":63}},{"start":{"line":534,"column":63},"end":{"line":534,"column":null}}],"line":534},"67":{"loc":{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},"type":"if","locations":[{"start":{"line":535,"column":8},"end":{"line":539,"column":null}},{"start":{},"end":{}}],"line":535},"68":{"loc":{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},"type":"if","locations":[{"start":{"line":536,"column":10},"end":{"line":536,"column":null}},{"start":{},"end":{}}],"line":536},"69":{"loc":{"start":{"line":536,"column":14},"end":{"line":536,"column":65}},"type":"binary-expr","locations":[{"start":{"line":536,"column":14},"end":{"line":536,"column":43}},{"start":{"line":536,"column":43},"end":{"line":536,"column":65}}],"line":536},"70":{"loc":{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},"type":"if","locations":[{"start":{"line":537,"column":10},"end":{"line":537,"column":null}},{"start":{},"end":{}}],"line":537},"71":{"loc":{"start":{"line":537,"column":14},"end":{"line":537,"column":65}},"type":"binary-expr","locations":[{"start":{"line":537,"column":14},"end":{"line":537,"column":43}},{"start":{"line":537,"column":43},"end":{"line":537,"column":65}}],"line":537},"72":{"loc":{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},"type":"if","locations":[{"start":{"line":538,"column":10},"end":{"line":538,"column":null}},{"start":{},"end":{}}],"line":538},"73":{"loc":{"start":{"line":538,"column":14},"end":{"line":538,"column":68}},"type":"binary-expr","locations":[{"start":{"line":538,"column":14},"end":{"line":538,"column":42}},{"start":{"line":538,"column":42},"end":{"line":538,"column":68}}],"line":538},"74":{"loc":{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},"type":"if","locations":[{"start":{"line":543,"column":8},"end":{"line":543,"column":null}},{"start":{},"end":{}}],"line":543},"75":{"loc":{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},"type":"if","locations":[{"start":{"line":546,"column":8},"end":{"line":546,"column":null}},{"start":{},"end":{}}],"line":546},"76":{"loc":{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},"type":"if","locations":[{"start":{"line":547,"column":8},"end":{"line":547,"column":null}},{"start":{},"end":{}}],"line":547},"77":{"loc":{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},"type":"if","locations":[{"start":{"line":550,"column":8},"end":{"line":550,"column":null}},{"start":{},"end":{}}],"line":550},"78":{"loc":{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},"type":"if","locations":[{"start":{"line":553,"column":8},"end":{"line":553,"column":null}},{"start":{},"end":{}}],"line":553},"79":{"loc":{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},"type":"if","locations":[{"start":{"line":556,"column":8},"end":{"line":556,"column":null}},{"start":{},"end":{}}],"line":556},"80":{"loc":{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},"type":"if","locations":[{"start":{"line":557,"column":8},"end":{"line":557,"column":null}},{"start":{},"end":{}}],"line":557},"81":{"loc":{"start":{"line":574,"column":18},"end":{"line":574,"column":null}},"type":"cond-expr","locations":[{"start":{"line":574,"column":50},"end":{"line":574,"column":74}},{"start":{"line":574,"column":74},"end":{"line":574,"column":null}}],"line":574},"82":{"loc":{"start":{"line":575,"column":22},"end":{"line":575,"column":null}},"type":"binary-expr","locations":[{"start":{"line":575,"column":22},"end":{"line":575,"column":42}},{"start":{"line":575,"column":42},"end":{"line":575,"column":57}},{"start":{"line":575,"column":57},"end":{"line":575,"column":null}}],"line":575},"83":{"loc":{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},"type":"if","locations":[{"start":{"line":578,"column":4},"end":{"line":604,"column":null}},{"start":{},"end":{}}],"line":578},"84":{"loc":{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},"type":"if","locations":[{"start":{"line":582,"column":8},"end":{"line":582,"column":null}},{"start":{},"end":{}}],"line":582},"85":{"loc":{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},"type":"if","locations":[{"start":{"line":583,"column":8},"end":{"line":593,"column":null}},{"start":{},"end":{}}],"line":583},"86":{"loc":{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},"type":"if","locations":[{"start":{"line":592,"column":10},"end":{"line":592,"column":null}},{"start":{},"end":{}}],"line":592},"87":{"loc":{"start":{"line":592,"column":14},"end":{"line":592,"column":34}},"type":"binary-expr","locations":[{"start":{"line":592,"column":14},"end":{"line":592,"column":21}},{"start":{"line":592,"column":21},"end":{"line":592,"column":34}}],"line":592},"88":{"loc":{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},"type":"if","locations":[{"start":{"line":602,"column":8},"end":{"line":602,"column":null}},{"start":{},"end":{}}],"line":602},"89":{"loc":{"start":{"line":607,"column":4},"end":{"line":628,"column":null}},"type":"switch","locations":[{"start":{"line":608,"column":6},"end":{"line":617,"column":null}},{"start":{"line":618,"column":6},"end":{"line":620,"column":null}},{"start":{"line":621,"column":6},"end":{"line":623,"column":null}},{"start":{"line":624,"column":6},"end":{"line":627,"column":null}}],"line":607},"90":{"loc":{"start":{"line":609,"column":21},"end":{"line":609,"column":null}},"type":"binary-expr","locations":[{"start":{"line":609,"column":21},"end":{"line":609,"column":39}},{"start":{"line":609,"column":39},"end":{"line":609,"column":null}}],"line":609},"91":{"loc":{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},"type":"if","locations":[{"start":{"line":610,"column":8},"end":{"line":610,"column":null}},{"start":{},"end":{}}],"line":610},"92":{"loc":{"start":{"line":610,"column":12},"end":{"line":610,"column":84}},"type":"binary-expr","locations":[{"start":{"line":610,"column":12},"end":{"line":610,"column":36}},{"start":{"line":610,"column":36},"end":{"line":610,"column":62}},{"start":{"line":610,"column":62},"end":{"line":610,"column":84}}],"line":610},"93":{"loc":{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},"type":"if","locations":[{"start":{"line":611,"column":8},"end":{"line":611,"column":null}},{"start":{},"end":{}}],"line":611},"94":{"loc":{"start":{"line":611,"column":12},"end":{"line":611,"column":58}},"type":"binary-expr","locations":[{"start":{"line":611,"column":12},"end":{"line":611,"column":37}},{"start":{"line":611,"column":37},"end":{"line":611,"column":58}}],"line":611},"95":{"loc":{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},"type":"if","locations":[{"start":{"line":612,"column":8},"end":{"line":612,"column":null}},{"start":{},"end":{}}],"line":612},"96":{"loc":{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},"type":"if","locations":[{"start":{"line":613,"column":8},"end":{"line":613,"column":null}},{"start":{},"end":{}}],"line":613},"97":{"loc":{"start":{"line":613,"column":12},"end":{"line":613,"column":81}},"type":"binary-expr","locations":[{"start":{"line":613,"column":12},"end":{"line":613,"column":35}},{"start":{"line":613,"column":35},"end":{"line":613,"column":59}},{"start":{"line":613,"column":59},"end":{"line":613,"column":81}}],"line":613},"98":{"loc":{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},"type":"if","locations":[{"start":{"line":614,"column":8},"end":{"line":614,"column":null}},{"start":{},"end":{}}],"line":614},"99":{"loc":{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},"type":"if","locations":[{"start":{"line":615,"column":8},"end":{"line":615,"column":null}},{"start":{},"end":{}}],"line":615},"100":{"loc":{"start":{"line":615,"column":12},"end":{"line":615,"column":60}},"type":"binary-expr","locations":[{"start":{"line":615,"column":12},"end":{"line":615,"column":36}},{"start":{"line":615,"column":36},"end":{"line":615,"column":60}}],"line":615},"101":{"loc":{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},"type":"if","locations":[{"start":{"line":626,"column":8},"end":{"line":626,"column":null}},{"start":{},"end":{}}],"line":626},"102":{"loc":{"start":{"line":639,"column":38},"end":{"line":639,"column":72}},"type":"default-arg","locations":[{"start":{"line":639,"column":58},"end":{"line":639,"column":72}}],"line":639},"103":{"loc":{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},"type":"if","locations":[{"start":{"line":640,"column":2},"end":{"line":640,"column":null}},{"start":{},"end":{}}],"line":640},"104":{"loc":{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},"type":"if","locations":[{"start":{"line":656,"column":2},"end":{"line":663,"column":null}},{"start":{},"end":{}}],"line":656},"105":{"loc":{"start":{"line":675,"column":16},"end":{"line":675,"column":null}},"type":"binary-expr","locations":[{"start":{"line":675,"column":16},"end":{"line":675,"column":27}},{"start":{"line":675,"column":27},"end":{"line":675,"column":null}}],"line":675},"106":{"loc":{"start":{"line":678,"column":17},"end":{"line":678,"column":null}},"type":"binary-expr","locations":[{"start":{"line":678,"column":17},"end":{"line":678,"column":35}},{"start":{"line":678,"column":35},"end":{"line":678,"column":null}}],"line":678}},"s":{"0":136,"1":136,"2":136,"3":12,"4":12,"5":249,"6":249,"7":249,"8":249,"9":249,"10":123,"11":126,"12":98,"13":28,"14":18,"15":10,"16":6,"17":4,"18":139,"19":139,"20":139,"21":139,"22":139,"23":139,"24":36,"25":103,"26":103,"27":47,"28":56,"29":56,"30":103,"31":154,"32":154,"33":154,"34":65,"35":49,"36":3,"37":18,"38":18,"39":2,"40":3,"41":2,"42":5,"43":3,"44":3,"45":1,"46":143,"47":143,"48":143,"49":143,"50":143,"51":63,"52":49,"53":1,"54":18,"55":18,"56":18,"57":1,"58":2,"59":1,"60":4,"61":1,"62":3,"63":0,"64":138,"65":138,"66":138,"67":138,"68":63,"69":49,"70":0,"71":0,"72":17,"73":17,"74":6,"75":11,"76":2,"77":9,"78":9,"79":9,"80":0,"81":2,"82":0,"83":3,"84":1,"85":3,"86":0,"87":139,"88":139,"89":139,"90":139,"91":139,"92":139,"93":64,"94":53,"95":64,"96":49,"97":46,"98":49,"99":1,"100":1,"101":1,"102":1,"103":1,"104":17,"105":17,"106":17,"107":17,"108":17,"109":2,"110":17,"111":0,"112":17,"113":1,"114":17,"115":0,"116":0,"117":0,"118":1,"119":1,"120":1,"121":1,"122":1,"123":4,"124":3,"125":4,"126":1,"127":1,"128":1,"129":0,"130":0,"131":0,"132":0,"133":0,"134":139,"135":140,"136":140,"137":140,"138":140,"139":140,"140":100,"141":100,"142":132,"143":9,"144":123,"145":99,"146":99,"147":99,"148":99,"149":123,"150":123,"151":7,"152":140,"153":18,"154":18,"155":7,"156":18,"157":1,"158":18,"159":1,"160":18,"161":8,"162":18,"163":0,"164":18,"165":0,"166":18,"167":4,"168":4,"169":1,"170":1,"171":1,"172":1,"173":1,"174":1,"175":140,"176":153,"177":149,"178":4,"179":5,"180":2,"181":1,"182":1,"183":7,"184":7,"185":7,"186":7,"187":2,"188":2},"f":{"0":136,"1":12,"2":249,"3":139,"4":154,"5":143,"6":138,"7":139,"8":140,"9":153,"10":2,"11":7},"b":{"0":[12,2],"1":[123,126],"2":[98,28],"3":[18,10],"4":[6,4],"5":[1,138],"6":[139,38,36],"7":[36,103],"8":[47,56],"9":[56,0],"10":[2,152],"11":[65,49,3,3,18,2,3,2,5,3,3],"12":[65,11,11],"13":[49,3,3],"14":[3,2,1],"15":[18,2],"16":[1,17],"17":[2,1],"18":[3,1],"19":[2,1],"20":[5,2],"21":[3,1],"22":[0,143],"23":[143,43,42],"24":[101,42],"25":[63,49,1,1,18,1,2,1,4,1,3],"26":[53,10],"27":[46,3],"28":[1,0],"29":[18,1],"30":[18,9],"31":[9,1],"32":[1,0],"33":[2,0],"34":[1,0],"35":[4,1],"36":[0,138],"37":[138,39,37],"38":[63,49,0,0,17,0,2,0,3,1,3],"39":[63,10],"40":[49,3],"41":[0,0],"42":[0,0],"43":[17,1],"44":[6,11],"45":[17,11],"46":[2,9],"47":[11,10],"48":[0,9],"49":[0,0],"50":[2,0],"51":[2,0],"52":[0,0],"53":[0,0],"54":[3,1],"55":[1,0],"56":[0,139],"57":[0,139],"58":[139,39,38],"59":[64,49,1,1,17,0,1,4,1,0],"60":[53,11],"61":[46,3],"62":[1,0],"63":[1,0],"64":[1,0],"65":[17,1],"66":[17,14,14],"67":[17,0],"68":[2,15],"69":[17,15],"70":[0,17],"71":[17,17],"72":[1,16],"73":[17,17],"74":[0,0],"75":[1,0],"76":[1,0],"77":[3,1],"78":[1,0],"79":[0,0],"80":[0,0],"81":[0,140],"82":[140,41,40],"83":[100,40],"84":[9,123],"85":[99,24],"86":[99,0],"87":[99,99],"88":[7,116],"89":[18,4,1,1],"90":[18,1],"91":[7,11],"92":[18,11,11],"93":[1,17],"94":[18,17],"95":[1,17],"96":[8,10],"97":[18,10,10],"98":[0,18],"99":[0,18],"100":[18,18],"101":[1,0],"102":[153],"103":[149,4],"104":[1,1],"105":[7,1],"106":[7,1]},"meta":{"lastBranch":107,"lastFunction":12,"lastStatement":189,"seen":{"f:322:16:322:48":0,"s:323:20:323:Infinity":0,"s:324:17:324:Infinity":1,"s:325:2:325:Infinity":2,"f:331:16:331:31":1,"s:332:16:332:Infinity":3,"s:333:2:333:Infinity":4,"b:333:9:333:36:333:36:333:Infinity":0,"f:339:16:339:35":2,"s:340:20:340:Infinity":5,"s:341:21:341:Infinity":6,"s:342:23:342:Infinity":7,"s:343:22:343:Infinity":8,"b:345:2:345:Infinity:undefined:undefined:undefined:undefined":1,"s:345:2:345:Infinity":9,"s:345:36:345:Infinity":10,"b:346:2:346:Infinity:undefined:undefined:undefined:undefined":2,"s:346:2:346:Infinity":11,"s:346:37:346:Infinity":12,"b:347:2:347:Infinity:undefined:undefined:undefined:undefined":3,"s:347:2:347:Infinity":13,"s:347:39:347:Infinity":14,"b:348:2:348:Infinity:undefined:undefined:undefined:undefined":4,"s:348:2:348:Infinity":15,"s:348:38:348:Infinity":16,"s:349:2:349:Infinity":17,"f:355:16:355:33":3,"s:356:30:356:Infinity":18,"s:357:34:357:Infinity":19,"s:359:2:373:Infinity":20,"s:360:18:360:Infinity":21,"b:360:50:360:74:360:74:360:Infinity":5,"s:361:21:361:Infinity":22,"b:361:21:361:41:361:41:361:56:361:56:361:Infinity":6,"b:363:4:363:Infinity:undefined:undefined:undefined:undefined":7,"s:363:4:363:Infinity":23,"s:363:19:363:Infinity":24,"s:365:17:365:Infinity":25,"b:366:4:370:Infinity:368:4:370:Infinity":8,"s:366:4:370:Infinity":26,"s:367:6:367:Infinity":27,"b:368:4:370:Infinity:undefined:undefined:undefined:undefined":9,"s:368:4:370:Infinity":28,"s:369:6:369:Infinity":29,"s:375:2:375:Infinity":30,"f:381:16:381:41":4,"s:382:2:411:Infinity":31,"s:383:18:383:Infinity":32,"b:383:50:383:74:383:74:383:Infinity":10,"b:386:6:387:Infinity:388:6:389:Infinity:390:6:390:Infinity:391:6:392:Infinity:393:6:395:Infinity:396:6:397:Infinity:398:6:399:Infinity:400:6:401:Infinity:402:6:403:Infinity:404:6:405:Infinity:406:6:407:Infinity":11,"s:385:4:408:Infinity":33,"s:387:8:387:Infinity":34,"b:387:23:387:43:387:43:387:58:387:58:387:64":12,"s:389:8:389:Infinity":35,"b:389:24:389:44:389:44:389:59:389:59:389:65":13,"s:392:8:392:Infinity":36,"b:392:23:392:43:392:43:392:58:392:58:392:64":14,"s:394:20:394:Infinity":37,"b:394:20:394:38:394:38:394:Infinity":15,"s:395:8:395:Infinity":38,"b:395:64:395:72:395:72:395:74":16,"s:397:8:397:Infinity":39,"b:397:23:397:41:397:41:397:48":17,"s:399:8:399:Infinity":40,"b:399:26:399:44:399:44:399:46":18,"s:401:8:401:Infinity":41,"b:401:24:401:46:401:46:401:53":19,"s:403:8:403:Infinity":42,"b:403:26:403:42:403:42:403:44":20,"s:405:8:405:Infinity":43,"b:405:25:405:39:405:39:405:41":21,"s:407:8:407:Infinity":44,"s:410:4:410:Infinity":45,"f:417:16:417:44":5,"s:418:2:456:Infinity":46,"s:419:18:419:Infinity":47,"b:419:50:419:74:419:74:419:Infinity":22,"s:420:21:420:Infinity":48,"b:420:21:420:41:420:41:420:56:420:56:420:Infinity":23,"s:421:21:421:Infinity":49,"b:421:32:421:64:421:64:421:Infinity":24,"b:424:6:425:Infinity:426:6:427:Infinity:428:6:428:Infinity:429:6:430:Infinity:431:6:440:Infinity:441:6:442:Infinity:443:6:444:Infinity:445:6:446:Infinity:447:6:448:Infinity:449:6:450:Infinity:451:6:452:Infinity":25,"s:423:4:453:Infinity":50,"s:425:8:425:Infinity":51,"b:425:26:425:52:425:52:425:Infinity":26,"s:427:8:427:Infinity":52,"b:427:26:427:60:427:60:427:Infinity":27,"s:430:8:430:Infinity":53,"b:430:26:430:52:430:52:430:Infinity":28,"s:432:14:432:Infinity":54,"b:432:21:432:39:432:39:432:43":29,"s:433:47:438:Infinity":55,"s:439:8:439:Infinity":56,"b:439:15:439:30:439:30:439:Infinity":30,"b:439:43:439:50:439:50:439:59":31,"s:442:8:442:Infinity":57,"b:442:32:442:50:442:50:442:57":32,"s:444:8:444:Infinity":58,"b:444:38:444:56:444:56:444:65":33,"s:446:8:446:Infinity":59,"b:446:32:446:56:446:56:446:67":34,"s:448:8:448:Infinity":60,"b:448:32:448:48:448:48:448:52":35,"s:450:8:450:Infinity":61,"s:452:8:452:Infinity":62,"s:455:4:455:Infinity":63,"f:462:16:462:Infinity":6,"s:465:2:504:Infinity":64,"s:466:18:466:Infinity":65,"b:466:50:466:74:466:74:466:Infinity":36,"s:467:21:467:Infinity":66,"b:467:21:467:41:467:41:467:56:467:56:467:Infinity":37,"b:470:6:471:Infinity:472:6:473:Infinity:474:6:474:Infinity:475:6:478:Infinity:479:6:488:Infinity:489:6:490:Infinity:491:6:492:Infinity:493:6:494:Infinity:495:6:496:Infinity:497:6:498:Infinity:499:6:500:Infinity":38,"s:469:4:501:Infinity":67,"s:471:8:471:Infinity":68,"b:471:39:471:51:471:51:471:59":39,"s:473:8:473:Infinity":69,"b:473:24:473:36:473:36:473:44":40,"s:476:23:476:Infinity":70,"b:476:43:476:89:476:89:476:Infinity":41,"s:477:8:477:Infinity":71,"b:477:25:477:37:477:37:477:45":42,"s:480:20:480:Infinity":72,"b:480:20:480:38:480:38:480:Infinity":43,"b:481:8:482:Infinity:undefined:undefined:undefined:undefined":44,"s:481:8:482:Infinity":73,"b:481:12:481:42:481:42:481:Infinity":45,"s:482:10:482:Infinity":74,"b:483:8:484:Infinity:undefined:undefined:undefined:undefined":46,"s:483:8:484:Infinity":75,"b:483:12:483:47:483:47:483:Infinity":47,"s:484:10:484:Infinity":76,"b:485:8:486:Infinity:undefined:undefined:undefined:undefined":48,"s:485:8:486:Infinity":77,"s:486:10:486:Infinity":78,"s:487:8:487:Infinity":79,"s:490:8:490:Infinity":80,"b:490:70:490:88:490:88:490:90":49,"s:492:8:492:Infinity":81,"b:492:45:492:63:492:63:492:65":50,"b:492:83:492:105:492:105:492:107":51,"s:494:8:494:Infinity":82,"b:494:38:494:62:494:62:494:67":52,"b:494:78:494:100:494:100:494:106":53,"s:496:8:496:Infinity":83,"b:496:40:496:56:496:56:496:69":54,"s:498:8:498:Infinity":84,"b:498:39:498:53:498:53:498:60":55,"s:500:8:500:Infinity":85,"s:503:4:503:Infinity":86,"f:510:16:510:29":7,"s:511:26:511:Infinity":87,"s:513:2:562:Infinity":88,"s:514:18:514:Infinity":89,"b:514:50:514:74:514:74:514:Infinity":56,"s:515:21:515:Infinity":90,"b:515:56:515:83:515:83:515:Infinity":57,"s:516:21:516:Infinity":91,"b:516:21:516:41:516:41:516:56:516:56:516:Infinity":58,"b:519:6:521:Infinity:522:6:524:Infinity:525:6:525:Infinity:526:6:529:Infinity:530:6:541:Infinity:542:6:544:Infinity:545:6:548:Infinity:549:6:551:Infinity:552:6:554:Infinity:555:6:558:Infinity":59,"s:518:4:559:Infinity":92,"b:520:8:520:Infinity:undefined:undefined:undefined:undefined":60,"s:520:8:520:Infinity":93,"s:520:22:520:Infinity":94,"s:521:8:521:Infinity":95,"b:523:8:523:Infinity:undefined:undefined:undefined:undefined":61,"s:523:8:523:Infinity":96,"s:523:22:523:Infinity":97,"s:524:8:524:Infinity":98,"b:527:8:527:Infinity:undefined:undefined:undefined:undefined":62,"s:527:8:527:Infinity":99,"s:527:22:527:Infinity":100,"b:528:8:528:Infinity:undefined:undefined:undefined:undefined":63,"s:528:8:528:Infinity":101,"s:528:31:528:Infinity":102,"b:528:62:528:95:528:95:528:101":64,"s:529:8:529:Infinity":103,"s:531:20:531:Infinity":104,"b:531:20:531:38:531:38:531:Infinity":65,"s:532:8:532:Infinity":105,"s:534:23:534:Infinity":106,"b:534:23:534:43:534:43:534:63:534:63:534:Infinity":66,"b:535:8:539:Infinity:undefined:undefined:undefined:undefined":67,"s:535:8:539:Infinity":107,"b:536:10:536:Infinity:undefined:undefined:undefined:undefined":68,"s:536:10:536:Infinity":108,"b:536:14:536:43:536:43:536:65":69,"s:536:65:536:Infinity":109,"b:537:10:537:Infinity:undefined:undefined:undefined:undefined":70,"s:537:10:537:Infinity":110,"b:537:14:537:43:537:43:537:65":71,"s:537:65:537:Infinity":111,"b:538:10:538:Infinity:undefined:undefined:undefined:undefined":72,"s:538:10:538:Infinity":112,"b:538:14:538:42:538:42:538:68":73,"s:538:68:538:Infinity":113,"s:540:8:540:Infinity":114,"b:543:8:543:Infinity:undefined:undefined:undefined:undefined":74,"s:543:8:543:Infinity":115,"s:543:28:543:Infinity":116,"s:544:8:544:Infinity":117,"b:546:8:546:Infinity:undefined:undefined:undefined:undefined":75,"s:546:8:546:Infinity":118,"s:546:28:546:Infinity":119,"b:547:8:547:Infinity:undefined:undefined:undefined:undefined":76,"s:547:8:547:Infinity":120,"s:547:25:547:Infinity":121,"s:548:8:548:Infinity":122,"b:550:8:550:Infinity:undefined:undefined:undefined:undefined":77,"s:550:8:550:Infinity":123,"s:550:26:550:Infinity":124,"s:551:8:551:Infinity":125,"b:553:8:553:Infinity:undefined:undefined:undefined:undefined":78,"s:553:8:553:Infinity":126,"s:553:24:553:Infinity":127,"s:554:8:554:Infinity":128,"b:556:8:556:Infinity:undefined:undefined:undefined:undefined":79,"s:556:8:556:Infinity":129,"s:556:32:556:Infinity":130,"b:557:8:557:Infinity:undefined:undefined:undefined:undefined":80,"s:557:8:557:Infinity":131,"s:557:34:557:Infinity":132,"s:558:8:558:Infinity":133,"s:564:2:564:Infinity":134,"f:570:16:570:32":8,"s:571:32:571:Infinity":135,"s:573:2:631:Infinity":136,"s:574:18:574:Infinity":137,"b:574:50:574:74:574:74:574:Infinity":81,"s:575:22:575:Infinity":138,"b:575:22:575:42:575:42:575:57:575:57:575:Infinity":82,"b:578:4:604:Infinity:undefined:undefined:undefined:undefined":83,"s:578:4:604:Infinity":139,"s:580:20:580:Infinity":140,"s:581:6:603:Infinity":141,"b:582:8:582:Infinity:undefined:undefined:undefined:undefined":84,"s:582:8:582:Infinity":142,"s:582:78:582:Infinity":143,"b:583:8:593:Infinity:undefined:undefined:undefined:undefined":85,"s:583:8:593:Infinity":144,"s:585:22:585:Infinity":145,"s:586:49:591:Infinity":146,"b:592:10:592:Infinity:undefined:undefined:undefined:undefined":86,"s:592:10:592:Infinity":147,"b:592:14:592:21:592:21:592:34":87,"s:592:34:592:Infinity":148,"s:595:47:601:Infinity":149,"b:602:8:602:Infinity:undefined:undefined:undefined:undefined":88,"s:602:8:602:Infinity":150,"s:602:26:602:Infinity":151,"b:608:6:617:Infinity:618:6:620:Infinity:621:6:623:Infinity:624:6:627:Infinity":89,"s:607:4:628:Infinity":152,"s:609:21:609:Infinity":153,"b:609:21:609:39:609:39:609:Infinity":90,"b:610:8:610:Infinity:undefined:undefined:undefined:undefined":91,"s:610:8:610:Infinity":154,"b:610:12:610:36:610:36:610:62:610:62:610:84":92,"s:610:84:610:Infinity":155,"b:611:8:611:Infinity:undefined:undefined:undefined:undefined":93,"s:611:8:611:Infinity":156,"b:611:12:611:37:611:37:611:58":94,"s:611:58:611:Infinity":157,"b:612:8:612:Infinity:undefined:undefined:undefined:undefined":95,"s:612:8:612:Infinity":158,"s:612:33:612:Infinity":159,"b:613:8:613:Infinity:undefined:undefined:undefined:undefined":96,"s:613:8:613:Infinity":160,"b:613:12:613:35:613:35:613:59:613:59:613:81":97,"s:613:81:613:Infinity":161,"b:614:8:614:Infinity:undefined:undefined:undefined:undefined":98,"s:614:8:614:Infinity":162,"s:614:36:614:Infinity":163,"b:615:8:615:Infinity:undefined:undefined:undefined:undefined":99,"s:615:8:615:Infinity":164,"b:615:12:615:36:615:36:615:60":100,"s:615:60:615:Infinity":165,"s:616:8:616:Infinity":166,"s:619:8:619:Infinity":167,"s:620:8:620:Infinity":168,"s:622:8:622:Infinity":169,"s:623:8:623:Infinity":170,"s:625:8:625:Infinity":171,"b:626:8:626:Infinity:undefined:undefined:undefined:undefined":101,"s:626:8:626:Infinity":172,"s:626:34:626:Infinity":173,"s:627:8:627:Infinity":174,"s:633:2:633:Infinity":175,"f:639:16:639:25":9,"b:639:58:639:72":102,"b:640:2:640:Infinity:undefined:undefined:undefined:undefined":103,"s:640:2:640:Infinity":176,"s:640:31:640:Infinity":177,"s:641:2:641:Infinity":178,"s:647:57:650:Infinity":179,"f:655:16:655:31":10,"b:656:2:663:Infinity:undefined:undefined:undefined:undefined":104,"s:656:2:663:Infinity":180,"s:657:4:662:Infinity":181,"s:665:2:665:Infinity":182,"f:671:16:671:31":11,"s:672:2:698:Infinity":183,"s:673:37:673:Infinity":184,"s:675:16:675:Infinity":185,"b:675:16:675:27:675:27:675:Infinity":105,"s:677:4:688:Infinity":186,"b:678:17:678:35:678:35:678:Infinity":106,"s:691:16:691:Infinity":187,"s:692:4:697:Infinity":188}}} +,"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/user-message.ts": {"path":"/Volumes/DATA/Development/AITYTECH/ai-tools/agentkits-memory/src/hooks/user-message.ts","statementMap":{"0":{"start":{"line":30,"column":4},"end":{"line":30,"column":null}},"1":{"start":{"line":31,"column":4},"end":{"line":31,"column":null}},"2":{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},"3":{"start":{"line":39,"column":6},"end":{"line":39,"column":null}},"4":{"start":{"line":47,"column":4},"end":{"line":91,"column":null}},"5":{"start":{"line":49,"column":6},"end":{"line":49,"column":null}},"6":{"start":{"line":52,"column":22},"end":{"line":52,"column":null}},"7":{"start":{"line":53,"column":23},"end":{"line":53,"column":null}},"8":{"start":{"line":54,"column":27},"end":{"line":54,"column":null}},"9":{"start":{"line":55,"column":26},"end":{"line":55,"column":null}},"10":{"start":{"line":58,"column":30},"end":{"line":58,"column":null}},"11":{"start":{"line":59,"column":6},"end":{"line":59,"column":null}},"12":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"13":{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},"14":{"start":{"line":63,"column":32},"end":{"line":63,"column":null}},"15":{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},"16":{"start":{"line":64,"column":30},"end":{"line":64,"column":null}},"17":{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},"18":{"start":{"line":65,"column":26},"end":{"line":65,"column":null}},"19":{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},"20":{"start":{"line":66,"column":29},"end":{"line":66,"column":null}},"21":{"start":{"line":67,"column":8},"end":{"line":67,"column":null}},"22":{"start":{"line":68,"column":8},"end":{"line":68,"column":null}},"23":{"start":{"line":70,"column":8},"end":{"line":70,"column":null}},"24":{"start":{"line":73,"column":6},"end":{"line":73,"column":null}},"25":{"start":{"line":76,"column":6},"end":{"line":76,"column":null}},"26":{"start":{"line":78,"column":6},"end":{"line":81,"column":null}},"27":{"start":{"line":84,"column":6},"end":{"line":84,"column":null}},"28":{"start":{"line":86,"column":6},"end":{"line":90,"column":null}},"29":{"start":{"line":99,"column":18},"end":{"line":99,"column":null}},"30":{"start":{"line":100,"column":2},"end":{"line":100,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":29,"column":2},"end":{"line":29,"column":14}},"loc":{"start":{"line":29,"column":63},"end":{"line":32,"column":null}},"line":29},"1":{"name":"(anonymous_1)","decl":{"start":{"line":37,"column":8},"end":{"line":37,"column":34}},"loc":{"start":{"line":37,"column":34},"end":{"line":41,"column":null}},"line":37},"2":{"name":"(anonymous_2)","decl":{"start":{"line":46,"column":8},"end":{"line":46,"column":16}},"loc":{"start":{"line":46,"column":65},"end":{"line":92,"column":null}},"line":46},"3":{"name":"createUserMessageHook","decl":{"start":{"line":98,"column":16},"end":{"line":98,"column":38}},"loc":{"start":{"line":98,"column":68},"end":{"line":101,"column":null}},"line":98}},"branchMap":{"0":{"loc":{"start":{"line":29,"column":42},"end":{"line":29,"column":63}},"type":"default-arg","locations":[{"start":{"line":29,"column":56},"end":{"line":29,"column":63}}],"line":29},"1":{"loc":{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},"type":"if","locations":[{"start":{"line":38,"column":4},"end":{"line":40,"column":null}},{"start":{},"end":{}}],"line":38},"2":{"loc":{"start":{"line":54,"column":27},"end":{"line":54,"column":null}},"type":"binary-expr","locations":[{"start":{"line":54,"column":27},"end":{"line":54,"column":62}},{"start":{"line":54,"column":62},"end":{"line":54,"column":null}}],"line":54},"3":{"loc":{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},"type":"if","locations":[{"start":{"line":62,"column":6},"end":{"line":71,"column":null}},{"start":{"line":69,"column":13},"end":{"line":71,"column":null}}],"line":62},"4":{"loc":{"start":{"line":62,"column":10},"end":{"line":62,"column":63}},"type":"binary-expr","locations":[{"start":{"line":62,"column":10},"end":{"line":62,"column":26}},{"start":{"line":62,"column":26},"end":{"line":62,"column":46}},{"start":{"line":62,"column":46},"end":{"line":62,"column":63}}],"line":62},"5":{"loc":{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},"type":"if","locations":[{"start":{"line":64,"column":8},"end":{"line":64,"column":null}},{"start":{},"end":{}}],"line":64},"6":{"loc":{"start":{"line":64,"column":67},"end":{"line":64,"column":94}},"type":"cond-expr","locations":[{"start":{"line":64,"column":86},"end":{"line":64,"column":92}},{"start":{"line":64,"column":92},"end":{"line":64,"column":94}}],"line":64},"7":{"loc":{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},"type":"if","locations":[{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},{"start":{},"end":{}}],"line":65},"8":{"loc":{"start":{"line":65,"column":63},"end":{"line":65,"column":86}},"type":"cond-expr","locations":[{"start":{"line":65,"column":78},"end":{"line":65,"column":84}},{"start":{"line":65,"column":84},"end":{"line":65,"column":86}}],"line":65},"9":{"loc":{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},"type":"if","locations":[{"start":{"line":66,"column":8},"end":{"line":66,"column":null}},{"start":{},"end":{}}],"line":66},"10":{"loc":{"start":{"line":66,"column":64},"end":{"line":66,"column":90}},"type":"cond-expr","locations":[{"start":{"line":66,"column":82},"end":{"line":66,"column":88}},{"start":{"line":66,"column":88},"end":{"line":66,"column":90}}],"line":66},"11":{"loc":{"start":{"line":89,"column":15},"end":{"line":89,"column":null}},"type":"cond-expr","locations":[{"start":{"line":89,"column":40},"end":{"line":89,"column":56}},{"start":{"line":89,"column":56},"end":{"line":89,"column":null}}],"line":89}},"s":{"0":3,"1":3,"2":2,"3":2,"4":3,"5":3,"6":2,"7":2,"8":2,"9":3,"10":3,"11":3,"12":3,"13":3,"14":1,"15":1,"16":1,"17":1,"18":1,"19":1,"20":1,"21":1,"22":1,"23":1,"24":2,"25":2,"26":2,"27":1,"28":1,"29":2,"30":2},"f":{"0":3,"1":2,"2":3,"3":2},"b":{"0":[3],"1":[2,0],"2":[2,2],"3":[1,1],"4":[3,1,1],"5":[1,0],"6":[0,1],"7":[1,0],"8":[0,1],"9":[1,0],"10":[0,1],"11":[1,0]},"meta":{"lastBranch":12,"lastFunction":4,"lastStatement":31,"seen":{"f:29:2:29:14":0,"b:29:56:29:63":0,"s:30:4:30:Infinity":0,"s:31:4:31:Infinity":1,"f:37:8:37:34":1,"b:38:4:40:Infinity:undefined:undefined:undefined:undefined":1,"s:38:4:40:Infinity":2,"s:39:6:39:Infinity":3,"f:46:8:46:16":2,"s:47:4:91:Infinity":4,"s:49:6:49:Infinity":5,"s:52:22:52:Infinity":6,"s:53:23:53:Infinity":7,"s:54:27:54:Infinity":8,"b:54:27:54:62:54:62:54:Infinity":2,"s:55:26:55:Infinity":9,"s:58:30:58:Infinity":10,"s:59:6:59:Infinity":11,"s:60:6:60:Infinity":12,"b:62:6:71:Infinity:69:13:71:Infinity":3,"s:62:6:71:Infinity":13,"b:62:10:62:26:62:26:62:46:62:46:62:63":4,"s:63:32:63:Infinity":14,"b:64:8:64:Infinity:undefined:undefined:undefined:undefined":5,"s:64:8:64:Infinity":15,"s:64:30:64:Infinity":16,"b:64:86:64:92:64:92:64:94":6,"b:65:8:65:Infinity:undefined:undefined:undefined:undefined":7,"s:65:8:65:Infinity":17,"s:65:26:65:Infinity":18,"b:65:78:65:84:65:84:65:86":8,"b:66:8:66:Infinity:undefined:undefined:undefined:undefined":9,"s:66:8:66:Infinity":19,"s:66:29:66:Infinity":20,"b:66:82:66:88:66:88:66:90":10,"s:67:8:67:Infinity":21,"s:68:8:68:Infinity":22,"s:70:8:70:Infinity":23,"s:73:6:73:Infinity":24,"s:76:6:76:Infinity":25,"s:78:6:81:Infinity":26,"s:84:6:84:Infinity":27,"s:86:6:90:Infinity":28,"b:89:40:89:56:89:56:89:Infinity":11,"f:98:16:98:38":3,"s:99:18:99:Infinity":29,"s:100:2:100:Infinity":30}}} +} diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index 6c68a46..ae1bcd3 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -553,6 +553,13 @@ describe('AI Enrichment Module', () => { expect(result!.completed).toBe('Done.'); }); + it('should strip plain ``` markdown fences (without json suffix)', () => { + const json = '```\n{"completed":"Done.","nextSteps":"None"}\n```'; + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.completed).toBe('Done.'); + }); + it('should return null for invalid JSON', () => { expect(parseSummaryResponse('not json')).toBeNull(); }); @@ -610,6 +617,22 @@ describe('AI Enrichment Module', () => { const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); expect(result).toBeNull(); }); + + it('should return null when runClaudePrint throws (catch block)', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => { throw new Error('Unexpected CLI error'); }); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); + + it('should return null when runClaudePrint returns null', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => null as unknown as string); + + const result = await enrichSummaryWithAI('Request: Fix bug', 'I fixed it.'); + expect(result).toBeNull(); + }); }); describe('error handling', () => { diff --git a/src/hooks/__tests__/handlers.test.ts b/src/hooks/__tests__/handlers.test.ts index 350951f..2f79258 100644 --- a/src/hooks/__tests__/handlers.test.ts +++ b/src/hooks/__tests__/handlers.test.ts @@ -403,6 +403,43 @@ describe('Hook Handlers', () => { } }); + it('should skip empty/no-op tool calls (both input and response are {})', async () => { + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Read', + toolInput: {}, + toolResponse: {}, + }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + + // Should NOT initialize service or store anything + // Verify by checking no observations exist + const service = new MemoryHookService(TEST_DIR); + await service.initialize(); + const observations = await service.getSessionObservations('test-session-123'); + await service.shutdown(); + + expect(observations.length).toBe(0); + }); + + it('should skip when toolInput is undefined and toolResponse is undefined', async () => { + const hook = trackHook(createObservationHook(TEST_DIR)); + const input = createTestInput({ + toolName: 'Bash', + toolInput: undefined, + toolResponse: undefined, + }); + + const result = await hook.execute(input); + + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + }); + it('should handle errors gracefully', async () => { const hook = new ObservationHook({ initialize: async () => { throw new Error('Test error'); }, @@ -473,6 +510,66 @@ describe('Hook Handlers', () => { expect(result.suppressOutput).toBe(true); }); + it('should spawn enrich worker when AI enrichment is enabled', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + + try { + // Set up session with observations + const service = new MemoryHookService(TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Test task'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.shutdown(); + + // Run summarize hook + const hook = trackHook(createSummarizeHook(TEST_DIR)); + const input = createTestInput(); + + const result = await hook.execute(input); + + // Hook should still succeed (worker spawn is fire-and-forget) + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + + it('should spawn enrich-summary process when AI enrichment enabled and transcriptPath provided', async () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + process.env.AGENTKITS_AI_ENRICHMENT = 'true'; + + try { + // Set up session + const service = new MemoryHookService(TEST_DIR); + await service.initSession('test-session-123', 'test-project', 'Test task'); + await service.storeObservation('test-session-123', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.shutdown(); + + // Run summarize hook with transcriptPath + const hook = trackHook(createSummarizeHook(TEST_DIR)); + const input = createTestInput({ + transcriptPath: '/tmp/test-transcript.jsonl', + }); + + const result = await hook.execute(input); + + // Hook should still succeed (spawn is fire-and-forget, spawn failure is caught) + expect(result.continue).toBe(true); + expect(result.suppressOutput).toBe(true); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + } + }); + it('should handle errors gracefully', async () => { const hook = new SummarizeHook({ initialize: async () => { throw new Error('Test error'); }, diff --git a/src/hooks/__tests__/service-queue-worker.test.ts b/src/hooks/__tests__/service-queue-worker.test.ts new file mode 100644 index 0000000..8a6be8b --- /dev/null +++ b/src/hooks/__tests__/service-queue-worker.test.ts @@ -0,0 +1,981 @@ +/** + * Tests for task queue, worker lifecycle, session summaries, + * user prompts, transcript extraction, and embedding text generation. + * + * Covers the uncovered lines in service.ts that the original + * service.test.ts did not reach. + * + * @module @agentkits/memory/hooks/__tests__/service-queue-worker + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'; +import * as path from 'node:path'; +import { MemoryHookService, extractLastAssistantMessage } from '../service.js'; +import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; + +const TEST_DIR = path.join(process.cwd(), '.test-queue-worker'); + +describe('MemoryHookService - Queue, Worker, Summaries', () => { + let service: MemoryHookService; + + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + mkdirSync(TEST_DIR, { recursive: true }); + + service = new MemoryHookService(TEST_DIR); + await service.initialize(); + }); + + afterEach(async () => { + try { await service.shutdown(); } catch { /* ignore */ } + resetAIEnrichmentCache(); + _setRunClaudePrintMockForTesting(null); + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true }); + } + }); + + // ===== Task Queue ===== + + describe('queueTask', () => { + it('should insert a task into the queue', async () => { + service.queueTask('embed', 'observations', 'obs_123'); + + // Verify via direct DB query through public methods + // We'll check by looking at processEmbeddingQueue behavior + // But we can also rely on the storeObservation auto-queueing test below + // For now, just ensure it doesn't throw + expect(true).toBe(true); + }); + + it('should not throw when db is null', async () => { + await service.shutdown(); + // After shutdown, db is null + expect(() => service.queueTask('embed', 'observations', 'obs_123')).not.toThrow(); + }); + + it('should queue both embed and enrich tasks when storing observation', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + + // Both tasks should be queued — processEmbeddingQueue should find the embed task + // processEnrichmentQueue should find the enrich task + // We can't easily query task_queue directly, but we verify via processing + }); + + it('should queue embed task when saving user prompt', async () => { + await service.initSession('s1', 'proj'); + const prompt = await service.saveUserPrompt('s1', 'proj', 'Hello world'); + + expect(prompt.id).toBeGreaterThan(0); + expect(prompt.promptNumber).toBe(1); + expect(prompt.promptText).toBe('Hello world'); + }); + + it('should queue embed task when saving session summary', async () => { + await service.initSession('s1', 'proj'); + const summary = await service.saveSessionSummary({ + sessionId: 's1', + project: 'proj', + request: 'Add feature', + completed: '1 file modified', + filesRead: ['a.ts'], + filesModified: ['b.ts'], + nextSteps: '', + notes: '', + promptNumber: 1, + }); + + expect(summary.id).toBeGreaterThan(0); + expect(summary.request).toBe('Add feature'); + expect(summary.completed).toBe('1 file modified'); + expect(summary.createdAt).toBeGreaterThan(0); + }); + }); + + // ===== User Prompts ===== + + describe('saveUserPrompt', () => { + it('should save multiple prompts with incrementing numbers', async () => { + await service.initSession('s1', 'proj'); + + const p1 = await service.saveUserPrompt('s1', 'proj', 'First prompt'); + const p2 = await service.saveUserPrompt('s1', 'proj', 'Second prompt'); + const p3 = await service.saveUserPrompt('s1', 'proj', 'Third prompt'); + + expect(p1.promptNumber).toBe(1); + expect(p2.promptNumber).toBe(2); + expect(p3.promptNumber).toBe(3); + }); + + it('should auto-create session if not exists', async () => { + const prompt = await service.saveUserPrompt('new-session', 'proj', 'Hello'); + + expect(prompt.id).toBeGreaterThan(0); + const session = service.getSession('new-session'); + expect(session).not.toBeNull(); + }); + }); + + describe('getSessionPrompts', () => { + it('should return prompts in order', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'First'); + await service.saveUserPrompt('s1', 'proj', 'Second'); + + const prompts = await service.getSessionPrompts('s1'); + + expect(prompts).toHaveLength(2); + expect(prompts[0].promptText).toBe('First'); + expect(prompts[1].promptText).toBe('Second'); + }); + + it('should return empty array for session with no prompts', async () => { + await service.initSession('s1', 'proj'); + const prompts = await service.getSessionPrompts('s1'); + expect(prompts).toHaveLength(0); + }); + }); + + describe('getRecentPrompts', () => { + it('should return prompts across sessions for project', async () => { + await service.initSession('s1', 'proj'); + await service.initSession('s2', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'Session 1 prompt'); + await service.saveUserPrompt('s2', 'proj', 'Session 2 prompt'); + + const prompts = await service.getRecentPrompts('proj'); + + expect(prompts).toHaveLength(2); + }); + + it('should not return prompts from other projects', async () => { + await service.initSession('s1', 'proj-a'); + await service.initSession('s2', 'proj-b'); + await service.saveUserPrompt('s1', 'proj-a', 'A prompt'); + await service.saveUserPrompt('s2', 'proj-b', 'B prompt'); + + const prompts = await service.getRecentPrompts('proj-a'); + expect(prompts).toHaveLength(1); + expect(prompts[0].promptText).toBe('A prompt'); + }); + }); + + describe('getPromptNumber', () => { + it('should return 0 for session with no prompts', async () => { + await service.initSession('s1', 'proj'); + expect(service.getPromptNumber('s1')).toBe(0); + }); + + it('should return correct count after saving prompts', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'First'); + await service.saveUserPrompt('s1', 'proj', 'Second'); + expect(service.getPromptNumber('s1')).toBe(2); + }); + + it('should return 0 when db is null', async () => { + await service.shutdown(); + expect(service.getPromptNumber('s1')).toBe(0); + }); + }); + + // ===== Session Summaries ===== + + describe('generateStructuredSummary', () => { + it('should summarize observations by type', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm test' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'WebSearch', { query: 'test' }, {}, TEST_DIR); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.completed).toContain('file(s) modified'); + expect(summary.completed).toContain('file(s) read'); + expect(summary.completed).toContain('command(s) executed'); + expect(summary.completed).toContain('search(es)'); + expect(summary.filesRead).toContain('a.ts'); + expect(summary.filesModified).toContain('b.ts'); + }); + + it('should include user prompts in request field', async () => { + await service.initSession('s1', 'proj'); + await service.saveUserPrompt('s1', 'proj', 'Fix the bug'); + await service.saveUserPrompt('s1', 'proj', 'Also add tests'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.request).toContain('Fix the bug'); + expect(summary.request).toContain('Also add tests'); + expect(summary.request).toContain('[#1]'); + expect(summary.request).toContain('[#2]'); + }); + + it('should fallback to session prompt when no user_prompts exist', async () => { + await service.initSession('s1', 'proj', 'My initial task'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.request).toBe('My initial task'); + }); + + it('should include command notes', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm test' }, {}, TEST_DIR); + await service.storeObservation('s1', 'proj', 'Bash', { command: 'npm run build' }, {}, TEST_DIR); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.notes).toContain('Commands:'); + expect(summary.notes).toContain('npm test'); + expect(summary.notes).toContain('npm run build'); + }); + + it('should return empty for session with no observations', async () => { + await service.initSession('s1', 'proj'); + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.completed).toBe('No activity recorded'); + expect(summary.filesRead).toHaveLength(0); + expect(summary.filesModified).toHaveLength(0); + }); + + it('should truncate more than 5 commands with +N more', async () => { + await service.initSession('s1', 'proj'); + for (let i = 0; i < 8; i++) { + await service.storeObservation('s1', 'proj', 'Bash', { command: `cmd-${i}` }, {}, TEST_DIR); + } + + const summary = await service.generateStructuredSummary('s1'); + + expect(summary.notes).toContain('(+3 more)'); + }); + }); + + describe('saveSessionSummary', () => { + it('should persist summary to database', async () => { + await service.initSession('s1', 'proj'); + + const saved = await service.saveSessionSummary({ + sessionId: 's1', + project: 'proj', + request: 'Implement feature X', + completed: '3 files modified', + filesRead: ['a.ts', 'b.ts'], + filesModified: ['c.ts'], + nextSteps: 'Write tests', + notes: 'Commands: npm test', + promptNumber: 2, + }); + + const summaries = await service.getRecentSummaries('proj'); + expect(summaries).toHaveLength(1); + expect(summaries[0].request).toBe('Implement feature X'); + expect(summaries[0].completed).toBe('3 files modified'); + expect(summaries[0].filesRead).toEqual(['a.ts', 'b.ts']); + expect(summaries[0].filesModified).toEqual(['c.ts']); + expect(summaries[0].nextSteps).toBe('Write tests'); + expect(summaries[0].notes).toBe('Commands: npm test'); + expect(summaries[0].promptNumber).toBe(2); + }); + }); + + describe('getRecentSummaries', () => { + it('should return summaries in reverse chronological order', async () => { + await service.initSession('s1', 'proj'); + await service.initSession('s2', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'First', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + await service.saveSessionSummary({ + sessionId: 's2', project: 'proj', request: 'Second', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const summaries = await service.getRecentSummaries('proj'); + + expect(summaries).toHaveLength(2); + expect(summaries[0].request).toBe('Second'); + expect(summaries[1].request).toBe('First'); + }); + + it('should respect limit parameter', async () => { + await service.initSession('s1', 'proj'); + for (let i = 0; i < 5; i++) { + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: `Summary ${i}`, + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + } + + const summaries = await service.getRecentSummaries('proj', 2); + expect(summaries).toHaveLength(2); + }); + }); + + // ===== Enrich Session Summary ===== + + describe('enrichSessionSummary', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should enrich summary with AI data', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Fix bug', + completed: '1 file modified', filesRead: [], filesModified: ['a.ts'], + nextSteps: '', notes: '', promptNumber: 1, + }); + + // Create a fake transcript file + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ + type: 'assistant', + message: { content: 'I fixed the bug in a.ts by updating the validation logic.' }, + }) + '\n'); + + // Mock AI response + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + completed: 'Fixed validation bug in a.ts by correcting the regex pattern.', + nextSteps: 'Consider adding unit tests for the validation function.', + })); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(true); + + // Verify the enriched data + const summaries = await service.getRecentSummaries('proj'); + expect(summaries[0].completed).toBe('Fixed validation bug in a.ts by correcting the regex pattern.'); + expect(summaries[0].nextSteps).toBe('Consider adding unit tests for the validation function.'); + }); + + it('should return false for non-existent session', async () => { + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, '{}'); + + const result = await service.enrichSessionSummary('non-existent', transcriptPath); + expect(result).toBe(false); + }); + + it('should return false when transcript has no assistant message', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Task', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ type: 'user', message: { content: 'Hi' } }) + '\n'); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(false); + }); + + it('should return false when AI enrichment fails', async () => { + await service.initSession('s1', 'proj'); + await service.saveSessionSummary({ + sessionId: 's1', project: 'proj', request: 'Task', + completed: '', filesRead: [], filesModified: [], + nextSteps: '', notes: '', promptNumber: 1, + }); + + const transcriptPath = path.join(TEST_DIR, 'transcript.jsonl'); + writeFileSync(transcriptPath, JSON.stringify({ + type: 'assistant', + message: { content: 'Done.' }, + }) + '\n'); + + _setRunClaudePrintMockForTesting(() => 'not valid json'); + + const result = await service.enrichSessionSummary('s1', transcriptPath); + expect(result).toBe(false); + }); + }); + + // ===== Worker Lock File ===== + + describe('ensureWorkerRunning', () => { + it('should not throw when called', () => { + // Worker spawning will fail (no dist/hooks/cli.js) but shouldn't throw + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-embed.lock')).not.toThrow(); + }); + + it('should skip when lock file has alive PID', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-worker.lock'); + + // Write current process PID (which is alive) + writeFileSync(lockFile, String(process.pid)); + + // Should return early (worker "alive") + service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-worker.lock'); + + // Lock file should still exist (not cleaned up) + expect(existsSync(lockFile)).toBe(true); + }); + + it('should clean up stale lock file with dead PID', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-stale.lock'); + + // Write a PID that doesn't exist (very high number) + writeFileSync(lockFile, '999999999'); + + // Should clean up stale lock and try to spawn + service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-stale.lock'); + + // Lock file should be recreated (atomic O_EXCL) with '0' placeholder + // or cleaned up if spawn failed — either way the stale one was removed + }); + + it('should clean up lock file with invalid content', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-invalid.lock'); + + writeFileSync(lockFile, 'not-a-pid'); + + // Should handle gracefully + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-invalid.lock')).not.toThrow(); + }); + + it('should clean up lock file with pid 0', () => { + const lockDir = path.join(TEST_DIR, '.claude/memory'); + const lockFile = path.join(lockDir, 'test-zero.lock'); + + writeFileSync(lockFile, '0'); + + // PID 0 is invalid — should clean up + expect(() => service.ensureWorkerRunning(TEST_DIR, 'embed-session', 'test-zero.lock')).not.toThrow(); + }); + }); + + // ===== Process Enrichment Queue ===== + + describe('processEnrichmentQueue', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + // Clean up lock files + const lockFile = path.join(TEST_DIR, '.claude/memory', 'enrich-worker.lock'); + try { unlinkSync(lockFile); } catch { /* ignore */ } + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should return 0 when queue is empty', async () => { + const count = await service.processEnrichmentQueue(); + expect(count).toBe(0); + }); + + it('should process queued enrich tasks', async () => { + // Store an observation (auto-queues enrich task) + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, { content: 'hello' }, TEST_DIR); + + // Mock AI response for enrichment + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Reading a.ts', + narrative: 'Examined a.ts to understand its contents.', + facts: ['File is small'], + concepts: ['typescript'], + })); + + const count = await service.processEnrichmentQueue(); + expect(count).toBe(1); + + // Verify observation was enriched + const obs = await service.getSessionObservations('s1'); + expect(obs[0].subtitle).toBe('Reading a.ts'); + expect(obs[0].narrative).toBe('Examined a.ts to understand its contents.'); + }); + + it('should still count task when enrichObservation returns false (graceful failure)', async () => { + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + + // Mock AI that throws — enrichObservation catches internally and returns false + _setRunClaudePrintMockForTesting(() => { throw new Error('Network error'); }); + + const count = await service.processEnrichmentQueue(); + // enrichObservation catches the error internally (returns false, doesn't throw) + // so processEnrichmentQueue treats it as processed and deletes the task + expect(count).toBe(1); + + // Queue should be empty now — second run processes nothing + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Reading a.ts', + narrative: 'Read a.ts.', + facts: [], + concepts: [], + })); + + const count2 = await service.processEnrichmentQueue(); + expect(count2).toBe(0); + }); + + it('should clean up lock file after processing', async () => { + const lockFile = path.join(TEST_DIR, '.claude/memory', 'enrich-worker.lock'); + + await service.processEnrichmentQueue(); + + // Lock file should be cleaned up + expect(existsSync(lockFile)).toBe(false); + }); + + it('should handle session_summaries task type (skip without error)', async () => { + // Manually queue a session_summaries enrich task + service.queueTask('enrich', 'session_summaries', '1'); + + const count = await service.processEnrichmentQueue(); + // Should count it as processed (deleted from queue) + expect(count).toBe(1); + }); + }); + + // ===== Process Embedding Queue ===== + + describe('processEmbeddingQueue', () => { + afterEach(() => { + // Clean up lock files + const lockFile = path.join(TEST_DIR, '.claude/memory', 'embed-worker.lock'); + try { unlinkSync(lockFile); } catch { /* ignore */ } + }); + + it('should return 0 when queue is empty and no missing embeddings', async () => { + const count = await service.processEmbeddingQueue(); + expect(count).toBe(0); + }); + + it('should clean up lock file after processing', async () => { + const lockFile = path.join(TEST_DIR, '.claude/memory', 'embed-worker.lock'); + + await service.processEmbeddingQueue(); + + expect(existsSync(lockFile)).toBe(false); + }); + + it('should skip queue items with unknown target_table', async () => { + service.queueTask('embed', 'nonexistent_table', '1'); + + // processEmbeddingQueue requires the embedding model which we can't load in tests + // But we can verify the queue item gets cleaned up by the unknown table check + // The function will attempt to load LocalEmbeddingsService — this test verifies + // the early exit for unknown tables + try { + await service.processEmbeddingQueue(); + } catch { + // May fail on model loading — that's OK, the table check happens before embedding + } + }); + }); +}); + +// ===== Schema Migration ===== + +describe('MemoryHookService - Schema Migration', () => { + const MIGRATE_DIR = path.join(process.cwd(), '.test-migration'); + + beforeEach(() => { + if (existsSync(MIGRATE_DIR)) { + rmSync(MIGRATE_DIR, { recursive: true }); + } + mkdirSync(MIGRATE_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(MIGRATE_DIR)) { + rmSync(MIGRATE_DIR, { recursive: true }); + } + }); + + it('should migrate old observations table (add missing columns)', async () => { + // Manually create a DB with old schema (missing new columns) + const Database = (await import('better-sqlite3')).default; + const dbDir = path.join(MIGRATE_DIR, '.claude', 'memory'); + mkdirSync(dbDir, { recursive: true }); + const dbPath = path.join(dbDir, 'memory.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + // Create observations table WITHOUT the new columns (prompt_number, files_read, etc.) + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT DEFAULT '', + completed TEXT DEFAULT '', + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT DEFAULT '', + notes TEXT DEFAULT '', + prompt_number INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + db.close(); + + // Now initialize MemoryHookService — it should detect missing columns and migrate + const service = new MemoryHookService(MIGRATE_DIR); + await service.initialize(); + + // Verify migration worked by storing an observation with new fields + await service.initSession('s1', 'proj'); + const obs = await service.storeObservation('s1', 'proj', 'Read', { file_path: 'test.ts' }, {}, MIGRATE_DIR); + + // Should have subtitle, narrative, etc. (these use the new columns) + const observations = await service.getSessionObservations('s1'); + expect(observations.length).toBe(1); + expect(observations[0].subtitle).toBeDefined(); + expect(observations[0].narrative).toBeDefined(); + expect(observations[0].promptNumber).toBeDefined(); + + await service.shutdown(); + }); + + it('should add embedding column to session tables during migration', async () => { + const Database = (await import('better-sqlite3')).default; + const dbDir = path.join(MIGRATE_DIR, '.claude', 'memory'); + mkdirSync(dbDir, { recursive: true }); + const dbPath = path.join(dbDir, 'memory.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + // Create tables WITHOUT embedding column + db.exec(` + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + project TEXT NOT NULL, + prompt TEXT DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + observation_count INTEGER DEFAULT 0, + summary TEXT, + status TEXT DEFAULT 'active' + ) + `); + db.exec(` + CREATE TABLE observations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_response TEXT, + cwd TEXT, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT, + prompt_number INTEGER, + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + subtitle TEXT, + narrative TEXT, + facts TEXT DEFAULT '[]', + concepts TEXT DEFAULT '[]' + ) + `); + db.exec(` + CREATE TABLE user_prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + prompt_number INTEGER NOT NULL, + prompt_text TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE session_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT DEFAULT '', + completed TEXT DEFAULT '', + files_read TEXT DEFAULT '[]', + files_modified TEXT DEFAULT '[]', + next_steps TEXT DEFAULT '', + notes TEXT DEFAULT '', + prompt_number INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE task_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_type TEXT NOT NULL, + target_table TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT DEFAULT 'pending' + ) + `); + db.close(); + + // Initialize service — should add embedding BLOB to observations, user_prompts, session_summaries + const service = new MemoryHookService(MIGRATE_DIR); + await service.initialize(); + + // Verify by checking the column exists (store and retrieve data that uses embedding) + // The embedding column is BLOB — it won't break regular operations + await service.initSession('s1', 'proj'); + await service.storeObservation('s1', 'proj', 'Read', { file_path: 'x.ts' }, {}, MIGRATE_DIR); + + const observations = await service.getSessionObservations('s1'); + expect(observations.length).toBe(1); + + await service.shutdown(); + + // Double-verify by opening DB directly and checking columns + const db2 = new Database(dbPath); + const obsCols = db2.prepare("PRAGMA table_info(observations)").all() as Array<{ name: string }>; + const promptCols = db2.prepare("PRAGMA table_info(user_prompts)").all() as Array<{ name: string }>; + const summaryCols = db2.prepare("PRAGMA table_info(session_summaries)").all() as Array<{ name: string }>; + db2.close(); + + expect(obsCols.some(c => c.name === 'embedding')).toBe(true); + expect(promptCols.some(c => c.name === 'embedding')).toBe(true); + expect(summaryCols.some(c => c.name === 'embedding')).toBe(true); + }); +}); + +// ===== extractLastAssistantMessage (exported standalone function) ===== + +describe('extractLastAssistantMessage', () => { + const TRANSCRIPT_DIR = path.join(process.cwd(), '.test-transcript'); + + beforeEach(() => { + if (existsSync(TRANSCRIPT_DIR)) rmSync(TRANSCRIPT_DIR, { recursive: true }); + mkdirSync(TRANSCRIPT_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TRANSCRIPT_DIR)) rmSync(TRANSCRIPT_DIR, { recursive: true }); + }); + + it('should return null for empty path', () => { + expect(extractLastAssistantMessage('')).toBeNull(); + }); + + it('should return null for non-existent file', () => { + expect(extractLastAssistantMessage('/nonexistent/path.jsonl')).toBeNull(); + }); + + it('should return null for empty file', () => { + const p = path.join(TRANSCRIPT_DIR, 'empty.jsonl'); + writeFileSync(p, ''); + expect(extractLastAssistantMessage(p)).toBeNull(); + }); + + it('should extract last assistant message (string content)', () => { + const p = path.join(TRANSCRIPT_DIR, 'transcript.jsonl'); + const lines = [ + JSON.stringify({ type: 'user', message: { content: 'Hello' } }), + JSON.stringify({ type: 'assistant', message: { content: 'I will help you.' } }), + JSON.stringify({ type: 'user', message: { content: 'Thanks' } }), + JSON.stringify({ type: 'assistant', message: { content: 'All done. The bug is fixed.' } }), + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('All done. The bug is fixed.'); + }); + + it('should extract text from array content (skip tool_use blocks)', () => { + const p = path.join(TRANSCRIPT_DIR, 'array.jsonl'); + const msg = { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'I found the issue.' }, + { type: 'tool_use', id: 'tool1', name: 'Read', input: {} }, + { type: 'text', text: 'Here is the fix.' }, + ], + }, + }; + writeFileSync(p, JSON.stringify(msg)); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('I found the issue.\nHere is the fix.'); + }); + + it('should strip system-reminder tags', () => { + const p = path.join(TRANSCRIPT_DIR, 'reminders.jsonl'); + const msg = { + type: 'assistant', + message: { + content: 'Real content here. This should be stripped More content.', + }, + }; + writeFileSync(p, JSON.stringify(msg)); + + const result = extractLastAssistantMessage(p); + expect(result).toContain('Real content here.'); + expect(result).toContain('More content.'); + expect(result).not.toContain('system-reminder'); + expect(result).not.toContain('This should be stripped'); + }); + + it('should skip lines that are not parseable JSON', () => { + const p = path.join(TRANSCRIPT_DIR, 'malformed.jsonl'); + const lines = [ + 'not json', + JSON.stringify({ type: 'assistant', message: { content: 'Valid message.' } }), + '{ broken json', + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Valid message.'); + }); + + it('should skip assistant messages without content', () => { + const p = path.join(TRANSCRIPT_DIR, 'nocontent.jsonl'); + const lines = [ + JSON.stringify({ type: 'assistant', message: { content: 'Good message.' } }), + JSON.stringify({ type: 'assistant', message: {} }), // no content + JSON.stringify({ type: 'assistant' }), // no message + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Good message.'); + }); + + it('should skip assistant messages with empty text array', () => { + const p = path.join(TRANSCRIPT_DIR, 'emptyarray.jsonl'); + const lines = [ + JSON.stringify({ type: 'assistant', message: { content: 'First good one.' } }), + JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }] } }), + ]; + writeFileSync(p, lines.join('\n')); + + const result = extractLastAssistantMessage(p); + // The second message has only tool_use, no text — should fall back to first + expect(result).toBe('First good one.'); + }); + + it('should truncate very long messages to 5000 chars', () => { + const p = path.join(TRANSCRIPT_DIR, 'long.jsonl'); + const longText = 'A'.repeat(10000); + writeFileSync(p, JSON.stringify({ + type: 'assistant', + message: { content: longText }, + })); + + const result = extractLastAssistantMessage(p); + expect(result).not.toBeNull(); + expect(result!.length).toBe(5000); + }); + + it('should return null when only user messages exist', () => { + const p = path.join(TRANSCRIPT_DIR, 'useronly.jsonl'); + writeFileSync(p, JSON.stringify({ type: 'user', message: { content: 'Hello' } })); + + expect(extractLastAssistantMessage(p)).toBeNull(); + }); + + it('should return null when path is a directory (readFileSync throws)', () => { + // existsSync returns true for directories, but readFileSync throws + const dir = path.join(TRANSCRIPT_DIR, 'subdir'); + mkdirSync(dir, { recursive: true }); + expect(extractLastAssistantMessage(dir)).toBeNull(); + }); + + it('should collapse triple+ newlines to double newlines', () => { + const p = path.join(TRANSCRIPT_DIR, 'newlines.jsonl'); + writeFileSync(p, JSON.stringify({ + type: 'assistant', + message: { content: 'Line one.\n\n\n\n\nLine two.' }, + })); + + const result = extractLastAssistantMessage(p); + expect(result).toBe('Line one.\n\nLine two.'); + }); +}); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index 4599a25..dee4579 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -432,6 +432,11 @@ describe('Hook Types Utilities', () => { const subtitle = generateObservationSubtitle('CustomTool', {}); expect(subtitle).toBe('Using CustomTool tool'); }); + + it('should handle generateObservationSubtitle catch (unparseable string input)', () => { + const subtitle = generateObservationSubtitle('Read', 'invalid json {{{'); + expect(subtitle).toContain('Read'); + }); }); describe('generateObservationNarrative', () => { @@ -467,6 +472,54 @@ describe('Hook Types Utilities', () => { const narrative = generateObservationNarrative('CustomTool', {}); expect(narrative).toBe('Used CustomTool tool.'); }); + + it('should generate narrative for Edit tool', () => { + const narrative = generateObservationNarrative('Edit', { file_path: '/src/utils.ts', old_string: 'const x = 1' }); + expect(narrative).toContain('/src/utils.ts'); + expect(narrative).toContain('Edited'); + expect(narrative).toContain('const x = 1'); + }); + + it('should generate narrative for MultiEdit tool', () => { + const narrative = generateObservationNarrative('MultiEdit', { file_path: '/src/app.ts' }); + expect(narrative).toContain('/src/app.ts'); + expect(narrative).toContain('Edited'); + // No old_string provided — should show 'code' as fallback + expect(narrative).toContain('code'); + }); + + it('should generate narrative for Glob tool', () => { + const narrative = generateObservationNarrative('Glob', { pattern: '**/*.tsx' }); + expect(narrative).toContain('**/*.tsx'); + expect(narrative).toContain('Searched'); + }); + + it('should generate narrative for Task tool', () => { + const narrative = generateObservationNarrative('Task', { description: 'explore code', subagent_type: 'Explore' }); + expect(narrative).toContain('Explore'); + expect(narrative).toContain('explore code'); + expect(narrative).toContain('Delegated'); + }); + + it('should generate narrative for WebSearch tool', () => { + const narrative = generateObservationNarrative('WebSearch', { query: 'react hooks' }); + expect(narrative).toContain('react hooks'); + }); + + it('should generate narrative for WebFetch tool', () => { + const narrative = generateObservationNarrative('WebFetch', { url: 'https://docs.example.com' }); + expect(narrative).toContain('https://docs.example.com'); + }); + + it('should handle generateObservationNarrative catch (unparseable string input)', () => { + const narrative = generateObservationNarrative('Read', 'invalid json {{{'); + expect(narrative).toBe('Used Read tool.'); + }); + + it('should generate narrative for Bash git commands', () => { + const narrative = generateObservationNarrative('Bash', { command: 'git status' }); + expect(narrative).toContain('git'); + }); }); describe('extractFacts', () => { @@ -507,6 +560,49 @@ describe('Hook Types Utilities', () => { const facts = extractFacts('Read', null, null); expect(facts).toEqual([]); }); + + it('should extract facts from Glob', () => { + const facts = extractFacts('Glob', { pattern: '**/*.ts' }, {}); + expect(facts).toContain('Pattern searched: **/*.ts'); + }); + + it('should extract facts from Grep', () => { + const facts = extractFacts('Grep', { pattern: 'TODO', path: 'src/' }, {}); + expect(facts).toContain('Code pattern searched: TODO'); + expect(facts).toContain('Search scope: src/'); + }); + + it('should extract facts from WebFetch', () => { + const facts = extractFacts('WebFetch', { url: 'https://example.com' }, {}); + expect(facts).toContain('URL fetched: https://example.com'); + }); + + it('should extract facts from Task', () => { + const facts = extractFacts('Task', { description: 'Find files', subagent_type: 'Explore' }, {}); + expect(facts).toContain('Sub-task: Find files'); + expect(facts).toContain('Agent type: Explore'); + }); + + it('should extract test failed facts from Bash', () => { + const facts = extractFacts('Bash', { command: 'npm test' }, { stdout: '2 tests failed ✗' }); + expect(facts).toContain('Tests failed'); + }); + + it('should extract error facts from Bash', () => { + const facts = extractFacts('Bash', { command: 'tsc' }, { stdout: 'Error: something went wrong' }); + expect(facts).toContain('Errors encountered'); + }); + + it('should handle MultiEdit like Edit', () => { + const facts = extractFacts('MultiEdit', { file_path: 'app.ts', old_string: 'old' }, {}); + expect(facts).toContain('File modified: app.ts'); + expect(facts.some(f => f.includes('Code replaced'))).toBe(true); + }); + + it('should handle string toolInput (JSON string)', () => { + const facts = extractFacts('Read', JSON.stringify({ file_path: 'test.ts' }), '{}'); + expect(facts).toContain('File read: test.ts'); + }); }); describe('extractConcepts', () => { From ae648dba74de46e62f54ea566db7b1a6e67aeb3a Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 07:24:24 +0900 Subject: [PATCH 06/21] feat: Improve embedding handling and error management in web viewer and hook services --- src/cli/web-viewer.ts | 19 +++++++++++++++---- src/hooks/service.ts | 19 ++++++++++++------- src/hooks/summarize.ts | 1 + 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index 6ad301e..66a6960 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -2329,7 +2329,7 @@ function handleRequest( try { const embeddingsService = await getEmbeddingsService(); const result = await embeddingsService.embed(data.content); - embeddingBuffer = Buffer.from(result.embedding.buffer); + embeddingBuffer = Buffer.from(result.embedding); } catch (e) { console.warn('[WebViewer] Failed to generate embedding:', e); } @@ -2431,7 +2431,7 @@ function handleRequest( try { const embeddingsService = await getEmbeddingsService(); const result = await embeddingsService.embed(data.content); - embeddingBuffer = Buffer.from(result.embedding.buffer); + embeddingBuffer = Buffer.from(result.embedding); } catch (e) { console.warn('[WebViewer] Failed to generate embedding:', e); } @@ -2520,7 +2520,7 @@ function handleRequest( for (const entry of entries) { try { const result = await embeddingsService.embed(entry.content); - const embeddingBuffer = Buffer.from(result.embedding.buffer); + const embeddingBuffer = Buffer.from(result.embedding); updateStmt.run(embeddingBuffer, Date.now(), entry.id); success++; } catch (e) { @@ -2605,7 +2605,7 @@ function handleRequest( if (!text) { totalFailed++; continue; } try { const result = await embeddingsService.embed(text); - const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + const buffer = Buffer.from(result.embedding); updateStmt.run(buffer, row[idCol]); totalSuccess++; } catch { totalFailed++; } @@ -2758,3 +2758,14 @@ server.listen(PORT, () => { console.log(` Database: ${dbPath}\n`); console.log(` Press Ctrl+C to stop\n`); }); + +// Graceful shutdown: close server, DB, and embeddings service on SIGINT/SIGTERM +function cleanup() { + server.close(); + if (_db) { try { _db.close(); } catch { /* ignore */ } _db = null; } + if (_embeddingsService) { _embeddingsService = null; } + if (_searchEngine) { _searchEngine = null; } + process.exit(0); +} +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 11f50bd..e4e22e3 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -304,10 +304,13 @@ export class MemoryHookService { const promptNumber = this.getPromptNumber(sessionId); const { filesRead, filesModified } = extractFilePaths(toolName, toolInput); - // Truncate large responses - const inputStr = JSON.stringify(toolInput || {}); + // Truncate large responses (safe stringify handles circular refs) + const safeStringify = (val: unknown): string => { + try { return JSON.stringify(val || {}); } catch { return '{}'; } + }; + const inputStr = safeStringify(toolInput); const responseStr = truncate( - JSON.stringify(toolResponse || {}), + safeStringify(toolResponse), this.config.maxResponseSize ); @@ -470,8 +473,7 @@ export class MemoryHookService { // Write PID placeholder (will be overwritten by worker with its actual PID) try { writeFileSync(lockFile, '0'); - closeSync(fd); - } catch { + } finally { try { closeSync(fd); } catch { /* ignore */ } } @@ -483,6 +485,7 @@ export class MemoryHookService { stdio: 'ignore', env: { ...process.env }, }); + child.on('error', () => { /* spawn failure handled — lock cleaned below */ }); child.unref(); } catch { // Failed to spawn — clean up lock @@ -556,12 +559,13 @@ export class MemoryHookService { } const result = await embService.embed(text); - const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + const buffer = Buffer.from(result.embedding); this.db!.prepare(`UPDATE ${item.target_table} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, item.target_id); this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); count++; } catch { this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); + count++; // Still count failed attempts to prevent infinite loop on permanently failing tasks } } @@ -583,7 +587,7 @@ export class MemoryHookService { if (!text) continue; try { const result = await embService.embed(text); - const buffer = Buffer.from(result.embedding.buffer, result.embedding.byteOffset, result.embedding.byteLength); + const buffer = Buffer.from(result.embedding); this.db!.prepare(`UPDATE ${tableName} SET embedding = ? WHERE ${idCol} = ?`).run(buffer, row._rid); count++; } catch { /* skip */ } @@ -638,6 +642,7 @@ export class MemoryHookService { count++; } catch { this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); + count++; // Still count failed attempts to prevent infinite loop on permanently failing tasks } } } finally { diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index 4d34e7e..c4fae56 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -86,6 +86,7 @@ export class SummarizeHook implements EventHandler { stdio: 'ignore', env: { ...process.env }, }); + child.on('error', () => { /* spawn failure — silently ignore */ }); child.unref(); } catch { // Silently ignore From cdcf95fbd31ad922f4fc74d4268ab3393d1b8666 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 07:41:59 +0900 Subject: [PATCH 07/21] feat: add AI-based observation compression and session digest generation - Implemented functions for compressing individual observations and generating session-level digests using AI. - Added new CLI command 'compress-session' to process compression tasks in the background. - Enhanced the MemoryHookService to handle observation compression and session digest creation. - Introduced content hashing for deduplication of observations and prompts. - Updated database schema to support new features, including compressed summaries and parent session tracking. - Improved context display to show user statistics and memory usage. --- src/hooks/__tests__/ai-enrichment.test.ts | 244 ++++++++++++++ src/hooks/__tests__/service.test.ts | 318 +++++++++++++++++++ src/hooks/ai-enrichment.ts | 158 +++++++++ src/hooks/cli.ts | 20 +- src/hooks/context.ts | 14 + src/hooks/observation.ts | 2 + src/hooks/service.ts | 371 +++++++++++++++++++--- src/hooks/summarize.ts | 4 + src/hooks/types.ts | 56 +++- 9 files changed, 1144 insertions(+), 43 deletions(-) diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index ae1bcd3..eedd59a 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -18,6 +18,12 @@ import { parseSummaryResponse, buildSummaryPrompt, enrichSummaryWithAI, + buildCompressionPrompt, + parseCompressionResponse, + compressObservationWithAI, + buildSessionDigestPrompt, + parseSessionDigestResponse, + generateSessionDigestWithAI, _setRunClaudePrintMockForTesting, _setCliAvailableForTesting, } from '../ai-enrichment.js'; @@ -635,6 +641,244 @@ describe('AI Enrichment Module', () => { }); }); + describe('buildCompressionPrompt', () => { + it('should include tool name and input/response', () => { + const prompt = buildCompressionPrompt('Read', '{"file_path":"test.ts"}', 'file content'); + expect(prompt).toContain('Tool: Read'); + expect(prompt).toContain('Input: {"file_path":"test.ts"}'); + expect(prompt).toContain('Response: file content'); + expect(prompt).toContain('compressed_summary'); + }); + + it('should include context hints when provided', () => { + const prompt = buildCompressionPrompt('Read', '{}', '{}', 'Examining config', 'Read config file.'); + expect(prompt).toContain('Context: Examining config | Read config file.'); + }); + + it('should omit context line when no hints', () => { + const prompt = buildCompressionPrompt('Read', '{}', '{}'); + expect(prompt).not.toContain('Context:'); + }); + + it('should truncate long input to 1000 chars', () => { + const longInput = 'x'.repeat(3000); + const prompt = buildCompressionPrompt('Read', longInput, 'short'); + expect(prompt).toContain('x'.repeat(1000)); + expect(prompt).not.toContain('x'.repeat(1001)); + }); + + it('should truncate long response to 1000 chars', () => { + const longResponse = 'y'.repeat(3000); + const prompt = buildCompressionPrompt('Read', 'short', longResponse); + expect(prompt).toContain('y'.repeat(1000)); + expect(prompt).not.toContain('y'.repeat(1001)); + }); + }); + + describe('parseCompressionResponse', () => { + it('should parse valid compression JSON', () => { + const json = JSON.stringify({ compressed_summary: 'Read auth.ts to check login flow' }); + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Read auth.ts to check login flow'); + }); + + it('should strip markdown fences', () => { + const json = '```json\n{"compressed_summary":"Test summary"}\n```'; + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Test summary'); + }); + + it('should strip plain ``` fences', () => { + const json = '```\n{"compressed_summary":"Test summary"}\n```'; + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Test summary'); + }); + + it('should return null for invalid JSON', () => { + expect(parseCompressionResponse('not json')).toBeNull(); + }); + + it('should return null when compressed_summary is missing', () => { + const json = JSON.stringify({ other: 'field' }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should return null when compressed_summary is empty', () => { + const json = JSON.stringify({ compressed_summary: '' }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should return null when compressed_summary is not a string', () => { + const json = JSON.stringify({ compressed_summary: 123 }); + expect(parseCompressionResponse(json)).toBeNull(); + }); + + it('should truncate to 200 chars', () => { + const json = JSON.stringify({ compressed_summary: 'A'.repeat(300) }); + const result = parseCompressionResponse(json); + expect(result).not.toBeNull(); + expect(result!.compressed_summary.length).toBe(200); + }); + }); + + describe('compressObservationWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return compressed observation on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read auth module for login flow' }) + ); + + const result = await compressObservationWithAI('Read', '{"file_path":"auth.ts"}', 'export class Auth {}', 'Examining auth', 'Read auth module.'); + expect(result).not.toBeNull(); + expect(result!.compressed_summary).toBe('Read auth module for login flow'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns invalid response', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => 'not json'); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when CLI throws', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => { throw new Error('Error'); }); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should return null when env=false', async () => { + process.env.AGENTKITS_AI_ENRICHMENT = 'false'; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Should not reach here' }) + ); + + const result = await compressObservationWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + }); + + describe('buildSessionDigestPrompt', () => { + it('should include request, observations, and completion', () => { + const prompt = buildSessionDigestPrompt( + 'Fix auth bug', + ['Read auth.ts', 'Edited login handler', 'Ran tests'], + 'Fixed authentication', + ['src/auth.ts'] + ); + expect(prompt).toContain('Request: Fix auth bug'); + expect(prompt).toContain('Read auth.ts'); + expect(prompt).toContain('Edited login handler'); + expect(prompt).toContain('Completed: Fixed authentication'); + expect(prompt).toContain('Files modified: src/auth.ts'); + expect(prompt).toContain('"digest"'); + }); + + it('should omit files line when no files modified', () => { + const prompt = buildSessionDigestPrompt('Test', ['obs1'], 'Done', []); + expect(prompt).not.toContain('Files modified:'); + }); + + it('should limit observation summaries to 30', () => { + const obs = Array.from({ length: 50 }, (_, i) => `Obs ${i}`); + const prompt = buildSessionDigestPrompt('Test', obs, 'Done', []); + // Should contain obs 0-29 but not 30+ + expect(prompt).toContain('Obs 29'); + expect(prompt).not.toContain('Obs 30'); + }); + }); + + describe('parseSessionDigestResponse', () => { + it('should parse valid digest JSON', () => { + const json = JSON.stringify({ digest: 'Session fixed auth bug in 3 files.' }); + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Session fixed auth bug in 3 files.'); + }); + + it('should strip markdown fences', () => { + const json = '```json\n{"digest":"Test digest"}\n```'; + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Test digest'); + }); + + it('should return null for invalid JSON', () => { + expect(parseSessionDigestResponse('not json')).toBeNull(); + }); + + it('should return null when digest is missing', () => { + expect(parseSessionDigestResponse(JSON.stringify({ other: 'x' }))).toBeNull(); + }); + + it('should return null when digest is empty', () => { + expect(parseSessionDigestResponse(JSON.stringify({ digest: '' }))).toBeNull(); + }); + + it('should return null when digest is not a string', () => { + expect(parseSessionDigestResponse(JSON.stringify({ digest: 42 }))).toBeNull(); + }); + + it('should truncate to 600 chars', () => { + const json = JSON.stringify({ digest: 'D'.repeat(800) }); + const result = parseSessionDigestResponse(json); + expect(result).not.toBeNull(); + expect(result!.digest.length).toBe(600); + }); + }); + + describe('generateSessionDigestWithAI with mock CLI', () => { + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + }); + + it('should return digest on success', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ digest: 'Fixed auth bug by patching JWT validation.' }) + ); + + const result = await generateSessionDigestWithAI( + 'Fix auth', ['Read auth.ts', 'Edit auth.ts'], 'Fixed JWT', ['auth.ts'] + ); + expect(result).not.toBeNull(); + expect(result!.digest).toBe('Fixed auth bug by patching JWT validation.'); + }); + + it('should return null when CLI unavailable', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setCliAvailableForTesting(false); + + const result = await generateSessionDigestWithAI('Test', [], 'Done', []); + expect(result).toBeNull(); + }); + + it('should return null when CLI returns null', async () => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + _setRunClaudePrintMockForTesting(() => null as unknown as string); + + const result = await generateSessionDigestWithAI('Test', [], 'Done', []); + expect(result).toBeNull(); + }); + }); + describe('error handling', () => { it('should not throw on enrichment failure', async () => { delete process.env.AGENTKITS_AI_ENRICHMENT; diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 4f5b38e..587eecd 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -9,6 +9,7 @@ import { existsSync, rmSync, mkdirSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; +import { computeContentHash } from '../types.js'; const TEST_DIR = path.join(process.cwd(), '.test-memory-hooks'); @@ -616,6 +617,323 @@ describe('MemoryHookService', () => { }); }); + describe('content hash deduplication', () => { + it('should deduplicate identical prompts within 5-minute window', async () => { + await service.initSession('session-1', 'test-project'); + + const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); + const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Hello Claude'); + + // Should return the same prompt (dedup) + expect(prompt1.id).toBe(prompt2.id); + expect(prompt1.contentHash).toBeDefined(); + expect(prompt2.contentHash).toBe(prompt1.contentHash); + }); + + it('should allow different prompts in same session', async () => { + await service.initSession('session-1', 'test-project'); + + const prompt1 = await service.saveUserPrompt('session-1', 'test-project', 'First prompt'); + const prompt2 = await service.saveUserPrompt('session-1', 'test-project', 'Second prompt'); + + expect(prompt1.id).not.toBe(prompt2.id); + expect(prompt1.promptNumber).toBe(1); + expect(prompt2.promptNumber).toBe(2); + }); + + it('should deduplicate identical observations within 60-second window', async () => { + await service.initSession('session-1', 'test-project'); + + const obs1 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + const obs2 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Should return the same observation (dedup) + expect(obs1.id).toBe(obs2.id); + + // Session count should only increment once + const session = service.getSession('session-1'); + expect(session?.observationCount).toBe(1); + }); + + it('should allow same tool on different files', async () => { + await service.initSession('session-1', 'test-project'); + + const obs1 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + const obs2 = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'b.ts' }, {}, TEST_DIR + ); + + expect(obs1.id).not.toBe(obs2.id); + }); + }); + + describe('session resume detection', () => { + it('should link new session to recent parent in same project', async () => { + // Create first session + await service.initSession('session-old', 'test-project'); + + // Create second session shortly after + const session2 = await service.initSession('session-new', 'test-project'); + + expect(session2.parentSessionId).toBe('session-old'); + }); + + it('should not link sessions from different projects', async () => { + await service.initSession('session-1', 'project-a'); + const session2 = await service.initSession('session-2', 'project-b'); + + expect(session2.parentSessionId).toBeUndefined(); + }); + + it('should return existing session on re-init (no duplicate parent)', async () => { + await service.initSession('session-1', 'test-project'); + const first = await service.initSession('session-2', 'test-project'); + const second = await service.initSession('session-2', 'test-project'); + + // Re-init returns the same session + expect(first.sessionId).toBe(second.sessionId); + expect(first.parentSessionId).toBe(second.parentSessionId); + }); + }); + + describe('context XML wrapper', () => { + it('should wrap context in agentkits-memory-context tags', async () => { + await service.initSession('session-1', 'test-project', 'Test'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + const context = await service.getContext('test-project'); + + expect(context.markdown).toContain(''); + expect(context.markdown).toContain(''); + expect(context.markdown).toContain("Use these naturally when relevant. Don't force them into every response."); + }); + }); + + describe('context grouping by prompt', () => { + it('should group observations by prompt number when prompts exist', async () => { + await service.initSession('session-1', 'test-project'); + + // Save prompt first + await service.saveUserPrompt('session-1', 'test-project', 'Fix the bug'); + + // Store observation linked to prompt 1 + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'bug.ts' }, {}, TEST_DIR + ); + + const context = await service.getContext('test-project'); + + // Should have prompt-based grouping + expect(context.markdown).toContain('Prompt #1'); + expect(context.markdown).toContain('Fix the bug'); + }); + }); + + describe('compressObservation', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should compress an observation and clear raw data', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', + { file_path: 'auth.ts' }, { content: 'big file content' }, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read auth.ts for login flow analysis' }) + ); + + const result = await service.compressObservation(obs.id); + expect(result).toBe(true); + + // Verify compressed data in DB + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].compressedSummary).toBe('Read auth.ts for login flow analysis'); + expect(observations[0].isCompressed).toBe(true); + expect(observations[0].toolInput).toBe('{}'); // raw data cleared + expect(observations[0].toolResponse).toBe('{}'); // raw data cleared + }); + + it('should skip already-compressed observations', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'First compression' }) + ); + + // First compress + await service.compressObservation(obs.id); + + // Second compress should skip (already compressed) + const result = await service.compressObservation(obs.id); + expect(result).toBe(false); + }); + + it('should return false for non-existent observation', async () => { + await service.initialize(); + const result = await service.compressObservation('obs_nonexistent_0000'); + expect(result).toBe(false); + }); + + it('should return false when AI returns null', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', {}, {}, TEST_DIR + ); + + _setRunClaudePrintMockForTesting(() => 'not json'); + + const result = await service.compressObservation(obs.id); + expect(result).toBe(false); + }); + }); + + describe('compressSessionObservations', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should compress all session observations and create digest', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR); + await service.storeObservation('session-1', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR); + + // Save structured summary (needed for digest generation) + const structured = await service.generateStructuredSummary('session-1'); + await service.saveSessionSummary(structured); + + let callCount = 0; + _setRunClaudePrintMockForTesting(() => { + callCount++; + // First two calls: observation compression, third: session digest + if (callCount <= 2) { + return JSON.stringify({ compressed_summary: `Compressed obs ${callCount}` }); + } + return JSON.stringify({ digest: 'Session compressed all observations successfully.' }); + }); + + const result = await service.compressSessionObservations('session-1'); + expect(result.compressed).toBe(2); + expect(result.digestCreated).toBe(true); + }); + + it('should handle session with no summary gracefully', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation('session-1', 'test-project', 'Read', {}, {}, TEST_DIR); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Compressed' }) + ); + + const result = await service.compressSessionObservations('session-1'); + expect(result.compressed).toBe(1); + expect(result.digestCreated).toBe(false); // No summary = no digest + }); + }); + + describe('processCompressionQueue', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should process compress tasks from queue', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Queue a compress task manually + service.queueTask('compress', 'observations', obs.id); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Read test.ts' }) + ); + + const count = await service.processCompressionQueue(); + expect(count).toBe(1); + + // Verify observation is compressed + const observations = await service.getSessionObservations('session-1'); + expect(observations[0].compressedSummary).toBe('Read test.ts'); + expect(observations[0].isCompressed).toBe(true); + }); + + it('should return 0 when queue is empty', async () => { + await service.initialize(); + const count = await service.processCompressionQueue(); + expect(count).toBe(0); + }); + }); + + describe('computeContentHash', () => { + it('should produce consistent hashes', () => { + const hash1 = computeContentHash('a', 'b', 'c'); + const hash2 = computeContentHash('a', 'b', 'c'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different inputs', () => { + const hash1 = computeContentHash('a', 'b'); + const hash2 = computeContentHash('a', 'c'); + expect(hash1).not.toBe(hash2); + }); + + it('should produce 16-char hex string', () => { + const hash = computeContentHash('test'); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + }); + describe('createHookService factory', () => { it('should create service with default config', () => { const svc = createHookService(TEST_DIR); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index 1fdd73a..e8d04a1 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -334,3 +334,161 @@ export async function enrichSummaryWithAI( return null; } } + +// ===== Per-Observation Compression ===== + +/** + * Compressed observation data + */ +export interface CompressedObservation { + compressed_summary: string; +} + +/** + * Build prompt for compressing a single observation into a dense summary. + * Uses existing subtitle/narrative as hints for faster, more accurate compression. + */ +export function buildCompressionPrompt( + toolName: string, + toolInput: string, + toolResponse: string, + subtitle?: string, + narrative?: string +): string { + const hints = [subtitle, narrative].filter(Boolean).join(' | '); + return `Compress this tool observation into a single dense summary (50-150 chars). + +Tool: ${toolName} +${hints ? `Context: ${hints}\n` : ''}Input: ${toolInput.substring(0, 1000)} +Response: ${toolResponse.substring(0, 1000)} + +Return ONLY a JSON object (no markdown, no code fences): +{"compressed_summary": "dense summary here"}`; +} + +/** + * Parse compression response from AI + */ +export function parseCompressionResponse(text: string): CompressedObservation | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + if (typeof parsed.compressed_summary !== 'string' || !parsed.compressed_summary) return null; + + return { + compressed_summary: parsed.compressed_summary.substring(0, 200), + }; + } catch { + return null; + } +} + +/** + * Compress a single observation using `claude --print` CLI. + * Returns a dense 50-150 char summary suitable for context injection. + */ +export async function compressObservationWithAI( + toolName: string, + toolInput: string, + toolResponse: string, + subtitle?: string, + narrative?: string, + timeoutMs: number = 10000 +): Promise { + if (!isClaudeCliAvailable()) return null; + + try { + const prompt = buildCompressionPrompt(toolName, toolInput, toolResponse, subtitle, narrative); + const systemPrompt = 'You are a data compressor. Produce the shortest possible accurate summary. Return only valid JSON.'; + + const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseCompressionResponse(resultText); + } catch { + return null; + } +} + +// ===== Session-Level Digest ===== + +/** + * Session digest data from AI compression + */ +export interface SessionDigest { + digest: string; +} + +/** + * Build prompt for generating a compressed session digest. + * Takes the session's request, observation summaries, and completion info. + */ +export function buildSessionDigestPrompt( + request: string, + observationSummaries: string[], + completed: string, + filesModified: string[] +): string { + const obsText = observationSummaries.slice(0, 30).join('\n- '); + const filesText = filesModified.slice(0, 10).join(', '); + return `Compress this session into a single dense digest (200-500 chars). + +Request: ${request.substring(0, 500)} +Observations: +- ${obsText} +Completed: ${completed.substring(0, 300)} +${filesText ? `Files modified: ${filesText}\n` : ''} +Return ONLY a JSON object (no markdown, no code fences): +{"digest": "dense session digest here"}`; +} + +/** + * Parse session digest response from AI + */ +export function parseSessionDigestResponse(text: string): SessionDigest | null { + try { + let cleaned = text.trim(); + if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); + else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); + if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + if (typeof parsed.digest !== 'string' || !parsed.digest) return null; + + return { + digest: parsed.digest.substring(0, 600), + }; + } catch { + return null; + } +} + +/** + * Generate a session-level digest using `claude --print` CLI. + * Compresses an entire session into a 200-500 char digest. + */ +export async function generateSessionDigestWithAI( + request: string, + observationSummaries: string[], + completed: string, + filesModified: string[], + timeoutMs: number = 15000 +): Promise { + if (!isClaudeCliAvailable()) return null; + + try { + const prompt = buildSessionDigestPrompt(request, observationSummaries, completed, filesModified); + const systemPrompt = 'You are a session compressor. Produce the shortest possible accurate digest of a coding session. Return only valid JSON.'; + + const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + if (!resultText) return null; + return parseSessionDigestResponse(resultText); + } catch { + return null; + } +} diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index d6a32a6..e33bc13 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -72,7 +72,7 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session'); process.exit(1); } @@ -141,6 +141,24 @@ async function main(): Promise { process.exit(0); } + // Handle 'compress-session' command (no stdin, runs as background process) + // Processes the SQLite compression queue — compresses observations + generates session digests. + // Usage: compress-session + if (event === 'compress-session') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + try { + await svc.processCompressionQueue(); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); diff --git a/src/hooks/context.ts b/src/hooks/context.ts index 699ca12..d2f17f3 100644 --- a/src/hooks/context.ts +++ b/src/hooks/context.ts @@ -51,6 +51,20 @@ export class ContextHook implements EventHandler { const context = await this.service.getContext(input.project); const hasHistory = context.markdown && !context.markdown.includes('No previous session context'); + // Display status to user via stderr (merged from user-message hook) + const obsCount = context.recentObservations.length; + const sessionCount = context.sessionSummaries.length || context.previousSessions.length; + const promptCount = context.userPrompts.length; + if (obsCount > 0 || sessionCount > 0 || promptCount > 0) { + const stats: string[] = []; + if (sessionCount > 0) stats.push(`${sessionCount} session${sessionCount > 1 ? 's' : ''}`); + if (obsCount > 0) stats.push(`${obsCount} observation${obsCount > 1 ? 's' : ''}`); + if (promptCount > 0) stats.push(`${promptCount} prompt${promptCount > 1 ? 's' : ''}`); + console.error(`\n AgentKits Memory: ${stats.join(', ')}\n`); + } else { + console.error('\n AgentKits Memory: Fresh — use memory_save to start\n'); + } + if (hasHistory) { // Inject full context with history return { diff --git a/src/hooks/observation.ts b/src/hooks/observation.ts index 34479b0..6c2af59 100644 --- a/src/hooks/observation.ts +++ b/src/hooks/observation.ts @@ -24,6 +24,8 @@ const SKIP_TOOLS = new Set([ 'AskFollowupQuestion', 'AskUserQuestion', 'AttemptCompletion', + // Low-signal tools (directory listings add noise) + 'LS', // Skip our own memory tools (avoid capturing memory ops as observations) 'mcp__memory__memory_save', 'mcp__memory__memory_search', diff --git a/src/hooks/service.ts b/src/hooks/service.ts index e4e22e3..8957914 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -27,8 +27,11 @@ import { extractFacts, extractConcepts, truncate, + computeContentHash, + ContextConfig, + DEFAULT_CONTEXT_CONFIG, } from './types.js'; -import { enrichWithAI, enrichSummaryWithAI } from './ai-enrichment.js'; +import { enrichWithAI, enrichSummaryWithAI, compressObservationWithAI, generateSessionDigestWithAI } from './ai-enrichment.js'; /** * Memory Hook Service Configuration @@ -126,12 +129,25 @@ export class MemoryHookService { return existing; } + // Resume detection: find recent session in same project within 30 min + let parentSessionId: string | null = null; + const thirtyMinAgo = Date.now() - 30 * 60 * 1000; + const recentSession = this.db!.prepare(` + SELECT session_id FROM sessions + WHERE project = ? AND started_at > ? AND session_id != ? + ORDER BY started_at DESC LIMIT 1 + `).get(project, thirtyMinAgo, sessionId) as { session_id: string } | undefined; + + if (recentSession) { + parentSessionId = recentSession.session_id; + } + // Create new session const now = Date.now(); const result = this.db!.prepare(` - INSERT INTO sessions (session_id, project, prompt, started_at, observation_count, status) - VALUES (?, ?, ?, ?, 0, 'active') - `).run(sessionId, project, prompt || '', now); + INSERT INTO sessions (session_id, project, prompt, started_at, observation_count, status, parent_session_id) + VALUES (?, ?, ?, ?, 0, 'active', ?) + `).run(sessionId, project, prompt || '', now, parentSessionId); return { id: Number(result.lastInsertRowid), @@ -141,6 +157,7 @@ export class MemoryHookService { startedAt: now, observationCount: 0, status: 'active', + parentSessionId: parentSessionId || undefined, }; } @@ -155,14 +172,35 @@ export class MemoryHookService { // Ensure session exists await this.initSession(sessionId, project, promptText); + // Dedup: check for identical prompt in same project within 5 minutes + const contentHash = computeContentHash(project, promptText); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + const existing = this.db!.prepare(` + SELECT up.* FROM user_prompts up + JOIN sessions s ON s.session_id = up.session_id + WHERE up.content_hash = ? AND s.project = ? AND up.created_at > ? + LIMIT 1 + `).get(contentHash, project, fiveMinAgo) as Record | undefined; + + if (existing) { + return { + id: existing.id as number, + sessionId: existing.session_id as string, + promptNumber: existing.prompt_number as number, + promptText: existing.prompt_text as string, + createdAt: existing.created_at as number, + contentHash, + }; + } + // Get next prompt number const promptNumber = this.getPromptNumber(sessionId) + 1; const now = Date.now(); const result = this.db!.prepare(` - INSERT OR IGNORE INTO user_prompts (session_id, prompt_number, prompt_text, created_at) - VALUES (?, ?, ?, ?) - `).run(sessionId, promptNumber, promptText, now); + INSERT OR IGNORE INTO user_prompts (session_id, prompt_number, prompt_text, content_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(sessionId, promptNumber, promptText, contentHash, now); const id = result.changes > 0 ? Number(result.lastInsertRowid) : 0; @@ -177,6 +215,7 @@ export class MemoryHookService { promptNumber, promptText, createdAt: now, + contentHash, }; } @@ -314,6 +353,19 @@ export class MemoryHookService { this.config.maxResponseSize ); + // Dedup: check for identical observation in same session within 60 seconds + const contentHash = computeContentHash(sessionId, toolName, inputStr); + const oneMinAgo = now - 60 * 1000; + const existingObs = this.db!.prepare( + 'SELECT id FROM observations WHERE content_hash = ? AND session_id = ? AND timestamp > ? LIMIT 1' + ).get(contentHash, sessionId, oneMinAgo) as { id: string } | undefined; + + if (existingObs) { + // Return existing observation without re-inserting + const row = this.db!.prepare('SELECT * FROM observations WHERE id = ?').get(existingObs.id) as Record; + return this.rowToObservation(row); + } + // Template-based extraction only (fast, <10ms) // AI enrichment runs asynchronously via fire-and-forget process const subtitle = generateObservationSubtitle(toolName, toolInput, toolResponse); @@ -322,9 +374,9 @@ export class MemoryHookService { const concepts = extractConcepts(toolName, toolInput, toolResponse); this.db!.prepare(` - INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts)); + INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts, content_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, sessionId, project, toolName, inputStr, responseStr, cwd, now, type, title, promptNumber || null, JSON.stringify(filesRead), JSON.stringify(filesModified), subtitle, narrative, JSON.stringify(facts), JSON.stringify(concepts), contentHash); // Queue background tasks (embedding + AI enrichment) this.queueTask('embed', 'observations', id); @@ -390,14 +442,112 @@ export class MemoryHookService { return true; } + /** + * Compress a single observation using AI. + * Replaces raw tool_input/tool_response with a dense compressed_summary. + * Sets is_compressed=1 to indicate the raw data has been replaced. + */ + async compressObservation(id: string): Promise { + await this.ensureInitialized(); + + const row = this.db!.prepare( + 'SELECT tool_name, tool_input, tool_response, subtitle, narrative, is_compressed FROM observations WHERE id = ?' + ).get(id) as { tool_name: string; tool_input: string; tool_response: string; subtitle: string; narrative: string; is_compressed: number } | undefined; + + if (!row || row.is_compressed === 1) return false; + + const result = await compressObservationWithAI( + row.tool_name, row.tool_input, row.tool_response, row.subtitle, row.narrative + ).catch(() => null); + + if (!result) return false; + + // Write compressed summary and clear raw data to save space + this.db!.prepare(` + UPDATE observations + SET compressed_summary = ?, is_compressed = 1, tool_input = '{}', tool_response = '{}' + WHERE id = ? + `).run(result.compressed_summary, id); + + return true; + } + + /** + * Compress all observations for a session and generate a session digest. + * 1. Compresses each observation individually (10:1-25:1 ratio) + * 2. Generates a session-level digest from summaries (20:1-100:1 ratio) + * 3. Stores digest in session_digests table with embedding queued + */ + async compressSessionObservations(sessionId: string): Promise<{ compressed: number; digestCreated: boolean }> { + await this.ensureInitialized(); + + // Get all uncompressed observations for this session + const rows = this.db!.prepare( + 'SELECT id FROM observations WHERE session_id = ? AND is_compressed = 0' + ).all(sessionId) as { id: string }[]; + + let compressed = 0; + for (const row of rows) { + const ok = await this.compressObservation(row.id); + if (ok) compressed++; + } + + // Generate session digest + let digestCreated = false; + const summary = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sessionId) as Record | undefined; + + if (summary) { + // Collect observation summaries for digest input + const obsRows = this.db!.prepare( + 'SELECT compressed_summary, subtitle, title FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sessionId) as { compressed_summary: string | null; subtitle: string | null; title: string | null }[]; + + const obsSummaries = obsRows.map(r => r.compressed_summary || r.subtitle || r.title || '').filter(Boolean); + + const filesModified = JSON.parse((summary.files_modified as string) || '[]') as string[]; + const request = (summary.request as string) || ''; + const completed = (summary.completed as string) || ''; + + const digest = await generateSessionDigestWithAI( + request, obsSummaries, completed, filesModified + ).catch(() => null); + + if (digest) { + const project = (summary.project as string) || ''; + this.db!.prepare(` + INSERT OR REPLACE INTO session_digests (session_id, project, digest, observation_count, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(sessionId, project, digest.digest, obsRows.length, Date.now()); + + // Queue embedding for the digest + const digestRow = this.db!.prepare( + 'SELECT id FROM session_digests WHERE session_id = ?' + ).get(sessionId) as { id: number } | undefined; + if (digestRow) { + this.queueTask('embed', 'session_digests', digestRow.id); + } + + digestCreated = true; + } + } + + return { compressed, digestCreated }; + } + /** * Build embedding text for a session record based on table type. */ private getSessionEmbeddingText( - table: 'observations' | 'user_prompts' | 'session_summaries', + table: 'observations' | 'user_prompts' | 'session_summaries' | 'session_digests', row: Record ): string { if (table === 'observations') { + // Prefer compressed summary if available + if (row.compressed_summary) { + return (row.compressed_summary as string).trim(); + } const parts = [row.title, row.subtitle, row.narrative]; try { const concepts = JSON.parse((row.concepts as string) || '[]'); @@ -406,6 +556,8 @@ export class MemoryHookService { return (parts.filter(Boolean) as string[]).join(' ').trim(); } else if (table === 'user_prompts') { return ((row.prompt_text as string) || '').trim(); + } else if (table === 'session_digests') { + return ((row.digest as string) || '').trim(); } else { const parts = [row.request, row.completed, row.next_steps, row.notes]; return (parts.filter(Boolean) as string[]).join(' ').trim(); @@ -422,7 +574,7 @@ export class MemoryHookService { * Called from hook handlers — non-blocking, no model/API loading. */ queueTask( - taskType: 'embed' | 'enrich', + taskType: 'embed' | 'enrich' | 'compress', table: string, recordId: string | number ): void { @@ -515,6 +667,7 @@ export class MemoryHookService { observations: 'id', user_prompts: 'rowid', session_summaries: 'rowid', + session_digests: 'id', }; // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions @@ -652,6 +805,56 @@ export class MemoryHookService { return count; } + /** + * Process compression tasks from the queue. + * Compresses observations and generates session digests. + * Uses lock file to prevent concurrent workers. + */ + async processCompressionQueue(): Promise { + await this.ensureInitialized(); + + const lockFile = path.join(path.dirname(this.dbPath), 'compress-worker.lock'); + writeFileSync(lockFile, String(process.pid)); + + let count = 0; + try { + // Atomic claim: SELECT + UPDATE in a single transaction + const claimCompressTask = this.db!.transaction(() => { + const item = this.db!.prepare( + "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'compress' AND status = 'pending' ORDER BY id ASC LIMIT 1" + ).get() as { id: number; target_table: string; target_id: string } | undefined; + if (item) { + this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); + } + return item; + }); + + while (count < MemoryHookService.WORKER_BATCH_LIMIT) { + const item = claimCompressTask(); + + if (!item) break; + + try { + if (item.target_table === 'observations') { + await this.compressObservation(item.target_id); + } else if (item.target_table === 'sessions') { + // Compress all observations for a session + generate digest + await this.compressSessionObservations(item.target_id); + } + this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); + count++; + } catch { + this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); + count++; + } + } + } finally { + try { unlinkSync(lockFile); } catch { /* ignore */ } + } + + return count; + } + /** * Get observations for a session */ @@ -727,7 +930,8 @@ export class MemoryHookService { sessions: SessionRecord[], prompts: UserPrompt[], summaries: SessionSummary[], - project: string + project: string, + config: ContextConfig = DEFAULT_CONTEXT_CONFIG ): string { const lines: string[] = []; @@ -735,17 +939,19 @@ export class MemoryHookService { lines.push(''); // Tool-usage instruction header (CRITICAL for LLM tool adoption) - lines.push('> **Memory tools available** — Use MCP tools to search and manage project memory:'); - lines.push('> `memory_search(query)` → `memory_timeline(anchor)` → `memory_details(ids)` (3-layer workflow)'); - lines.push('> Also: `memory_save`, `memory_recall`, `memory_list`, `memory_delete`, `memory_update`, `memory_status`'); - lines.push(''); + if (config.showToolGuidance) { + lines.push('> **Memory tools available** — Use MCP tools to search and manage project memory:'); + lines.push('> `memory_search(query)` → `memory_timeline(anchor)` → `memory_details(ids)` (3-layer workflow)'); + lines.push('> Also: `memory_save`, `memory_recall`, `memory_list`, `memory_delete`, `memory_update`, `memory_status`'); + lines.push(''); + } // Structured summaries from previous sessions (most valuable context) - if (summaries.length > 0) { + if (config.showSummaries && summaries.length > 0) { lines.push('## Previous Session Summaries'); lines.push(''); - for (const summary of summaries.slice(0, 3)) { + for (const summary of summaries.slice(0, config.maxSummaries)) { const time = this.formatRelativeTime(summary.createdAt); lines.push(`### Session (${time})`); if (summary.request) { @@ -765,43 +971,78 @@ export class MemoryHookService { } // Recent user prompts (shows what user has been asking) - if (prompts.length > 0) { + if (config.showPrompts && prompts.length > 0) { lines.push('## Recent User Prompts'); lines.push(''); - for (const prompt of prompts.slice(0, 10)) { + for (const prompt of prompts.slice(0, config.maxPrompts)) { const time = this.formatRelativeTime(prompt.createdAt); lines.push(`- (${time}) ${prompt.promptText.substring(0, 150)}${prompt.promptText.length > 150 ? '...' : ''}`); } lines.push(''); } - // Recent observations with enriched details and IDs - if (observations.length > 0) { - lines.push('## Recent Activity'); - lines.push(''); + // Recent observations — group by prompt when available + if (config.showObservations && observations.length > 0) { + const maxObs = config.maxObservations; + const slicedObs = observations.slice(0, maxObs); - for (const obs of observations.slice(0, 10)) { - const time = this.formatRelativeTime(obs.timestamp); - const icon = this.getObservationIcon(obs.type); - const detail = obs.subtitle || obs.title || obs.toolName; - lines.push(`- ${icon} **${detail}** (${time}) [${obs.id}]`); - if (obs.narrative) { - lines.push(` ${obs.narrative}`); + // Check if we have prompt-linked observations to group + const hasPromptLinks = prompts.length > 0 && slicedObs.some(o => o.promptNumber); + + if (hasPromptLinks) { + lines.push('## Recent Activity'); + lines.push(''); + + // Group observations by prompt number + const obsByPrompt = new Map(); + for (const obs of slicedObs) { + const pn = obs.promptNumber || 0; + if (!obsByPrompt.has(pn)) obsByPrompt.set(pn, []); + obsByPrompt.get(pn)!.push(obs); } - if (obs.concepts && obs.concepts.length > 0) { - lines.push(` *Concepts: ${obs.concepts.join(', ')}*`); + + for (const [pn, obsGroup] of obsByPrompt) { + const prompt = prompts.find(p => p.promptNumber === pn); + if (prompt) { + lines.push(`### Prompt #${pn}: ${prompt.promptText.substring(0, 80)}${prompt.promptText.length > 80 ? '...' : ''}`); + } else if (pn > 0) { + lines.push(`### Prompt #${pn}`); + } + for (const obs of obsGroup) { + const icon = this.getObservationIcon(obs.type); + const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; + lines.push(`- ${icon} **${detail}** [${obs.id}]`); + } + lines.push(''); } + } else { + // Flat list (no prompt grouping) + lines.push('## Recent Activity'); + lines.push(''); + + for (const obs of slicedObs) { + const time = this.formatRelativeTime(obs.timestamp); + const icon = this.getObservationIcon(obs.type); + const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; + lines.push(`- ${icon} **${detail}** (${time}) [${obs.id}]`); + if (obs.narrative) { + lines.push(` ${obs.narrative}`); + } + if (obs.concepts && obs.concepts.length > 0) { + lines.push(` *Concepts: ${obs.concepts.join(', ')}*`); + } + } + lines.push(''); } - lines.push(''); } // Previous sessions (fallback if no structured summaries) - if (summaries.length === 0 && sessions.length > 0) { + if (config.showSummaries && summaries.length === 0 && sessions.length > 0) { lines.push('## Previous Sessions'); lines.push(''); - for (const session of sessions.slice(0, 3)) { + for (const session of sessions.slice(0, config.maxSummaries)) { const time = this.formatRelativeTime(session.startedAt); const status = session.status === 'completed' ? '✓' : '→'; lines.push(`### ${status} Session (${time})`); @@ -836,7 +1077,9 @@ export class MemoryHookService { lines.push(''); } - return lines.join('\n'); + // Wrap in XML tags with usage disclaimer + const content = lines.join('\n'); + return `\n${content}\nUse these naturally when relevant. Don't force them into every response.\n`; } /** @@ -1056,7 +1299,8 @@ export class MemoryHookService { ended_at INTEGER, observation_count INTEGER DEFAULT 0, summary TEXT, - status TEXT DEFAULT 'active' + status TEXT DEFAULT 'active', + parent_session_id TEXT ) `); @@ -1080,6 +1324,9 @@ export class MemoryHookService { facts TEXT DEFAULT '[]', concepts TEXT DEFAULT '[]', embedding BLOB, + content_hash TEXT, + compressed_summary TEXT, + is_compressed INTEGER DEFAULT 0, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) `); @@ -1093,6 +1340,7 @@ export class MemoryHookService { prompt_text TEXT NOT NULL, created_at INTEGER NOT NULL, embedding BLOB, + content_hash TEXT, UNIQUE(session_id, prompt_number), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) @@ -1130,7 +1378,20 @@ export class MemoryHookService { ) `); - // Indexes + // Session digests — compressed session-level summaries + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_digests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + project TEXT NOT NULL, + digest TEXT NOT NULL, + observation_count INTEGER, + created_at INTEGER NOT NULL, + embedding BLOB + ) + `); + + // Base indexes (columns that exist in original schema) this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_session ON observations(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_project ON observations(project)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_timestamp ON observations(timestamp)'); @@ -1138,10 +1399,15 @@ export class MemoryHookService { this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_session ON user_prompts(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_summaries_project ON session_summaries(project)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_digests_project ON session_digests(project)'); this.db.exec('CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status, task_type)'); - // Migration: add prompt_number to existing observations table + // Migration: add new columns to existing tables this.migrateSchema(); + + // Indexes on migrated columns (must run AFTER migration) + this.db.exec('CREATE INDEX IF NOT EXISTS idx_obs_content_hash ON observations(content_hash)'); + this.db.exec('CREATE INDEX IF NOT EXISTS idx_prompts_hash ON user_prompts(content_hash)'); } /** @@ -1162,6 +1428,9 @@ export class MemoryHookService { ['narrative', 'ALTER TABLE observations ADD COLUMN narrative TEXT'], ['facts', "ALTER TABLE observations ADD COLUMN facts TEXT DEFAULT '[]'"], ['concepts', "ALTER TABLE observations ADD COLUMN concepts TEXT DEFAULT '[]'"], + ['content_hash', 'ALTER TABLE observations ADD COLUMN content_hash TEXT'], + ['compressed_summary', 'ALTER TABLE observations ADD COLUMN compressed_summary TEXT'], + ['is_compressed', 'ALTER TABLE observations ADD COLUMN is_compressed INTEGER DEFAULT 0'], ]; for (const [column, sql] of migrations) { @@ -1170,6 +1439,22 @@ export class MemoryHookService { } } + // Migrate user_prompts: add content_hash + try { + const promptCols = this.db.prepare("PRAGMA table_info(user_prompts)").all() as Array<{ name: string }>; + if (!promptCols.some(c => c.name === 'content_hash')) { + this.db.exec('ALTER TABLE user_prompts ADD COLUMN content_hash TEXT'); + } + } catch { /* ignore */ } + + // Migrate sessions: add parent_session_id + try { + const sessionCols = this.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + if (!sessionCols.some(c => c.name === 'parent_session_id')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN parent_session_id TEXT'); + } + } catch { /* ignore */ } + // Add embedding column to all session tables for (const table of ['observations', 'user_prompts', 'session_summaries']) { const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; @@ -1193,6 +1478,7 @@ export class MemoryHookService { observationCount: row.observation_count as number, summary: row.summary as string | undefined, status: row.status as 'active' | 'completed' | 'abandoned', + parentSessionId: row.parent_session_id as string | undefined, }; } @@ -1215,6 +1501,9 @@ export class MemoryHookService { narrative: row.narrative as string | undefined, facts: JSON.parse((row.facts as string) || '[]'), concepts: JSON.parse((row.concepts as string) || '[]'), + contentHash: row.content_hash as string | undefined, + compressedSummary: row.compressed_summary as string | undefined, + isCompressed: (row.is_compressed as number) === 1, }; } diff --git a/src/hooks/summarize.ts b/src/hooks/summarize.ts index c4fae56..b58d109 100644 --- a/src/hooks/summarize.ts +++ b/src/hooks/summarize.ts @@ -73,6 +73,10 @@ export class SummarizeHook implements EventHandler { this.service.ensureWorkerRunning(input.cwd, 'embed-session', 'embed-worker.lock'); if (isAIEnrichmentEnabled()) { this.service.ensureWorkerRunning(input.cwd, 'enrich-session', 'enrich-worker.lock'); + + // Queue compression for this session's observations (runs after enrichment) + this.service.queueTask('compress', 'sessions', input.sessionId); + this.service.ensureWorkerRunning(input.cwd, 'compress-session', 'compress-worker.lock'); } // Summary enrichment needs transcript path — handled separately (not via queue) diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 3f18964..7b0a92f 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -2,11 +2,13 @@ * Hook Types for AgentKits Memory * * Lightweight hook system for auto-capturing Claude Code sessions. - * Based on claude-mem patterns but simplified for project-scoped storage. + * Project-scoped storage. * * @module @agentkits/memory/hooks/types */ +import { createHash } from 'node:crypto'; + // ===== Claude Code Hook Input Types ===== /** @@ -184,6 +186,15 @@ export interface Observation { /** Extracted concepts/topics */ concepts?: string[]; + + /** Content hash for deduplication */ + contentHash?: string; + + /** Compressed single-sentence summary (AI-generated) */ + compressedSummary?: string; + + /** Whether raw data has been replaced by compressed summary */ + isCompressed?: boolean; } /** @@ -226,6 +237,9 @@ export interface SessionRecord { /** Status */ status: 'active' | 'completed' | 'abandoned'; + + /** Parent session ID for session resume/continuation tracking */ + parentSessionId?: string; } /** @@ -246,6 +260,9 @@ export interface UserPrompt { /** Timestamp */ createdAt: number; + + /** Content hash for deduplication */ + contentHash?: string; } /** @@ -316,6 +333,30 @@ export interface MemoryContext { // ===== Utility Functions ===== +/** + * Context configuration for controlling what gets injected + */ +export interface ContextConfig { + showSummaries: boolean; + showPrompts: boolean; + showObservations: boolean; + showToolGuidance: boolean; + maxSummaries: number; + maxPrompts: number; + maxObservations: number; +} + +/** Default context configuration */ +export const DEFAULT_CONTEXT_CONFIG: ContextConfig = { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, +}; + /** * Generate observation ID */ @@ -325,6 +366,19 @@ export function generateObservationId(): string { return `obs_${timestamp}_${random}`; } +/** + * Compute content hash for deduplication. + * Uses SHA-256 truncated to 16 hex chars (64 bits) — sufficient for dedup. + * Computation: ~0.01ms. + */ +export function computeContentHash(...parts: string[]): string { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(part); + } + return hash.digest('hex').substring(0, 16); +} + /** * Get project name from cwd */ From 38e9cc6099db39d6bf127407a42ebbe092bc827b Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 08:35:29 +0900 Subject: [PATCH 08/21] feat: add intent detection and lifecycle management features - Implemented intent detection in storeObservation to add intent tags to concepts based on user prompts. - Added tests for intent detection and lifecycle management functionalities. - Enhanced lifecycle management with tasks for archiving, deleting, and compressing sessions and observations. - Introduced export/import functionalities for sessions, including deduplication of observations and prompts. - Updated CLI commands to support lifecycle tasks and export/import operations. - Expanded types to include lifecycle configuration and statistics. --- src/cli/web-viewer.ts | 181 ++++++++++++++- src/hooks/__tests__/service.test.ts | 252 ++++++++++++++++++++ src/hooks/__tests__/types.test.ts | 92 ++++++++ src/hooks/ai-enrichment.ts | 2 +- src/hooks/cli.ts | 84 ++++++- src/hooks/service.ts | 348 +++++++++++++++++++++++++++- src/hooks/types.ts | 257 ++++++++++++++++++++ 7 files changed, 1203 insertions(+), 13 deletions(-) diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index 66a6960..674d669 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -2146,6 +2146,8 @@ function getHTML(): string { for (const o of observations) { items.push({ type: 'observation', time: o.timestamp, sessionId: o.session_id, toolName: o.tool_name, title: o.title, obsType: o.type, promptNumber: o.prompt_number, + subtitle: o.subtitle, narrative: o.narrative, concepts: o.concepts, + compressedSummary: o.compressed_summary, isCompressed: o.is_compressed, hasEmbedding: !!o.hasEmbedding }); } totalItems = items.length; @@ -2178,28 +2180,47 @@ function getHTML(): string { : '--'; if (item.type === 'prompt') { + const promptId = 'prompt_' + Math.random().toString(36).slice(2, 8); + const promptText = item.text || ''; + const isLong = promptText.length > 200; return \`
PROMPT #\${item.promptNumber || '?'}
\${scoreBadge}\${vecBadge}
-
\${escapeHtml(truncate(item.text, 300))}
+
+ \${isLong ? '' + escapeHtml(truncate(promptText, 200)) + ' show more' : escapeHtml(promptText)} +
\`; } if (item.type === 'summary') { + const sumId = 'sum_' + Math.random().toString(36).slice(2, 8); let filesStr = ''; try { filesStr = JSON.parse(item.filesModified || '[]').join(', '); } catch {} + const requestText = item.request || ''; + const completedText = item.completed || ''; + const isLong = requestText.length > 150 || completedText.length > 150 || filesStr.length > 150; return \`
SUMMARY
\${scoreBadge}\${vecBadge}
- \${item.request ? '
Request: ' + escapeHtml(truncate(item.request, 250)) + '
' : ''} - \${item.completed ? '
Completed: ' + escapeHtml(truncate(item.completed, 250)) + '
' : ''} - \${filesStr ? '
Files: ' + escapeHtml(truncate(filesStr, 200)) + '
' : ''} - \${item.nextSteps ? '
Next: ' + escapeHtml(truncate(item.nextSteps, 200)) + '
' : ''} + + \${requestText ? '
Request: ' + escapeHtml(truncate(requestText, 150)) + '
' : ''} + \${completedText ? '
Completed: ' + escapeHtml(truncate(completedText, 150)) + '
' : ''} + \${filesStr ? '
Files: ' + escapeHtml(truncate(filesStr, 100)) + '
' : ''} + \${isLong ? 'show more' : ''} +
+
\`; } @@ -2207,12 +2228,26 @@ function getHTML(): string { // observation const icons = { read: '📖', write: '✏️', execute: '⚡', search: '🔍' }; const icon = icons[item.obsType] || '•'; - const subtitle = item.subtitle ? ' - ' + escapeHtml(truncate(item.subtitle, 120)) : ''; + const titleText = item.compressedSummary || item.title || ''; + const subtitleText = item.subtitle ? escapeHtml(item.subtitle) : ''; + const narrativeText = item.narrative ? escapeHtml(item.narrative) : ''; + let intentBadges = ''; + try { + const concepts = JSON.parse(item.concepts || '[]'); + const intents = concepts.filter(c => c.startsWith('intent:')).map(c => c.slice(7)); + if (intents.length > 0) intentBadges = ' [' + intents.join(', ') + ']'; + } catch {} + const hasDetails = subtitleText || narrativeText; + const obsId = 'obs_' + Math.random().toString(36).slice(2, 8); return \`
-
- \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(truncate(item.title, 100))}\${subtitle}\${scoreBadge} - \${vecBadge} \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''} +
+ \${icon} \${escapeHtml(item.toolName || '')} \${escapeHtml(titleText)}\${intentBadges}\${scoreBadge} + \${vecBadge} \${time}\${item.promptNumber ? ' · P#' + item.promptNumber : ''}\${item.isCompressed ? ' · Z' : ''}
+ \${hasDetails ? '' : ''}
\`; }).join(''); @@ -2221,6 +2256,21 @@ function getHTML(): string { } } + function toggleObsDetail(id) { + const el = document.getElementById(id); + if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; + } + + function toggleExpand(id) { + const short = document.getElementById(id + '_short'); + const full = document.getElementById(id + '_full'); + if (short && full) { + const showFull = short.style.display !== 'none'; + short.style.display = showFull ? 'none' : ''; + full.style.display = showFull ? '' : 'none'; + } + } + function truncate(text, max = 200) { if (!text || text.length <= max) return text || ''; return text.substring(0, max) + '…'; @@ -2742,6 +2792,119 @@ function handleRequest( return; } + // ===== Hook API Endpoints ===== + + // GET /api/hook/sessions - List hook sessions + if (url.pathname === '/api/hook/sessions' && method === 'GET') { + const project = url.searchParams.get('project') || undefined; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + try { + let rows: Record[]; + if (project) { + rows = db.prepare( + 'SELECT * FROM sessions WHERE project = ? ORDER BY started_at DESC LIMIT ?' + ).all(project, limit) as Record[]; + } else { + rows = db.prepare( + 'SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?' + ).all(limit) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(rows)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/observations - List hook observations + if (url.pathname === '/api/hook/observations' && method === 'GET') { + const project = url.searchParams.get('project') || undefined; + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + try { + let rows: Record[]; + if (project) { + rows = db.prepare( + 'SELECT id, session_id, project, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations WHERE project = ? ORDER BY timestamp DESC LIMIT ?' + ).all(project, limit) as Record[]; + } else { + rows = db.prepare( + 'SELECT id, session_id, project, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations ORDER BY timestamp DESC LIMIT ?' + ).all(limit) as Record[]; + } + res.writeHead(200); + res.end(JSON.stringify(rows)); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/session/:id - Session detail with observations and prompts + if (url.pathname.startsWith('/api/hook/session/') && method === 'GET') { + const sessionId = url.pathname.slice('/api/hook/session/'.length); + try { + const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId); + if (!session) { + res.writeHead(404); + res.end(JSON.stringify({ error: 'Session not found' })); + return; + } + const observations = db.prepare( + 'SELECT id, tool_name, timestamp, type, title, subtitle, narrative, facts, concepts, prompt_number, compressed_summary, is_compressed FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sessionId); + const prompts = db.prepare( + 'SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC' + ).all(sessionId); + const summary = db.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sessionId); + + res.writeHead(200); + res.end(JSON.stringify({ session, observations, prompts, summary })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // GET /api/hook/queue/status - Task queue stats + if (url.pathname === '/api/hook/queue/status' && method === 'GET') { + try { + const stats = db.prepare(` + SELECT task_type, status, COUNT(*) as count + FROM task_queue + GROUP BY task_type, status + `).all(); + const total = (db.prepare('SELECT COUNT(*) as c FROM task_queue').get() as { c: number }).c; + res.writeHead(200); + res.end(JSON.stringify({ total, breakdown: stats })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + + // POST /api/hook/cleanup - Clean old completed/failed queue tasks + if (url.pathname === '/api/hook/cleanup' && method === 'POST') { + try { + const oneDayAgo = Date.now() - 86400000; + const result = db.prepare( + "DELETE FROM task_queue WHERE status IN ('completed', 'failed') OR (status = 'processing' AND created_at < ?)" + ).run(oneDayAgo); + res.writeHead(200); + res.end(JSON.stringify({ deleted: result.changes })); + } catch (error) { + res.writeHead(500); + res.end(JSON.stringify({ error: String(error) })); + } + return; + } + res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); } catch (error) { diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 587eecd..9335823 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -941,4 +941,256 @@ describe('MemoryHookService', () => { expect(svc).toBeInstanceOf(MemoryHookService); }); }); + + // ===== Feature #5: Intent Tags ===== + + describe('intent detection in storeObservation', () => { + it('should add intent tags to concepts when prompt exists', async () => { + await service.initialize(); + await service.initSession('intent-session', 'test-project', 'Fix the login bug'); + await service.saveUserPrompt('intent-session', 'test-project', 'Fix the login bug'); + + const obs = await service.storeObservation( + 'intent-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'foo', new_string: 'bar' }, + { success: true }, TEST_DIR + ); + + expect(obs.concepts).toBeDefined(); + const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); + expect(intentConcepts.length).toBeGreaterThan(0); + expect(intentConcepts).toContain('intent:bugfix'); + }); + + it('should default to investigation for read without prompt', async () => { + await service.initialize(); + await service.initSession('intent-session-2', 'test-project'); + + const obs = await service.storeObservation( + 'intent-session-2', 'test-project', 'Read', + { file_path: 'src/app.ts' }, + { content: 'file contents' }, TEST_DIR + ); + + const intentConcepts = obs.concepts!.filter(c => c.startsWith('intent:')); + expect(intentConcepts).toContain('intent:investigation'); + }); + }); + + describe('getLatestPromptText', () => { + it('should return latest prompt text', async () => { + await service.initialize(); + await service.initSession('prompt-text-session', 'test-project', 'Hello'); + await service.saveUserPrompt('prompt-text-session', 'test-project', 'First prompt'); + await service.saveUserPrompt('prompt-text-session', 'test-project', 'Second prompt'); + + const text = service.getLatestPromptText('prompt-text-session'); + expect(text).toBe('Second prompt'); + }); + + it('should return null when no prompts exist', async () => { + await service.initialize(); + const text = service.getLatestPromptText('nonexistent-session'); + expect(text).toBeNull(); + }); + }); + + // ===== Feature #8: Lifecycle Management ===== + + describe('lifecycle management', () => { + it('should archive old completed sessions', async () => { + await service.initialize(); + + // Create an old completed session + await service.initSession('old-session', 'test-project', 'old task'); + await service.completeSession('old-session', 'Done'); + + // Manually backdate the session + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET ended_at = ? WHERE session_id = ?").run( + Date.now() - 40 * 86400000, // 40 days ago + 'old-session' + ); + + const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); + expect(result.archived).toBe(1); + + // Verify session is archived + const session = service.getSession('old-session'); + expect(session?.status).toBe('archived'); + }); + + it('should not archive recent sessions', async () => { + await service.initialize(); + + await service.initSession('recent-session', 'test-project', 'recent task'); + await service.completeSession('recent-session', 'Done'); + + const result = await service.runLifecycleTasks({ archiveAfterDays: 30 }); + expect(result.archived).toBe(0); + + const session = service.getSession('recent-session'); + expect(session?.status).toBe('completed'); + }); + + it('should delete archived sessions when autoDelete enabled', async () => { + await service.initialize(); + + await service.initSession('delete-session', 'test-project', 'delete task'); + await service.completeSession('delete-session', 'Done'); + + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( + Date.now() - 100 * 86400000, // 100 days ago + 'delete-session' + ); + + const result = await service.runLifecycleTasks({ + autoDelete: true, + deleteAfterDays: 90, + }); + expect(result.deleted).toBe(1); + expect(result.vacuumed).toBe(true); + + const session = service.getSession('delete-session'); + expect(session).toBeNull(); + }); + + it('should not delete when autoDelete is false (default)', async () => { + await service.initialize(); + + await service.initSession('keep-session', 'test-project', 'keep task'); + await service.completeSession('keep-session', 'Done'); + + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE sessions SET status = 'archived', ended_at = ? WHERE session_id = ?").run( + Date.now() - 100 * 86400000, + 'keep-session' + ); + + const result = await service.runLifecycleTasks({ autoDelete: false }); + expect(result.deleted).toBe(0); + + const session = service.getSession('keep-session'); + expect(session).not.toBeNull(); + }); + + it('should queue compression for old uncompressed observations', async () => { + await service.initialize(); + await service.initSession('compress-lc-session', 'test-project', 'task'); + + await service.storeObservation( + 'compress-lc-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + // Backdate the observation + // @ts-expect-error accessing private db for testing + service.db.prepare("UPDATE observations SET timestamp = ? WHERE session_id = ?").run( + Date.now() - 10 * 86400000, // 10 days ago + 'compress-lc-session' + ); + + const result = await service.runLifecycleTasks({ compressAfterDays: 7 }); + expect(result.compressed).toBe(1); + }); + }); + + describe('lifecycle stats', () => { + it('should return database statistics', async () => { + await service.initialize(); + + await service.initSession('stats-session', 'test-project', 'stats'); + await service.storeObservation( + 'stats-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + await service.saveUserPrompt('stats-session', 'test-project', 'test prompt'); + + const stats = await service.getLifecycleStats(); + expect(stats.totalSessions).toBeGreaterThanOrEqual(1); + expect(stats.activeSessions).toBeGreaterThanOrEqual(1); + expect(stats.totalObservations).toBeGreaterThanOrEqual(1); + expect(stats.totalPrompts).toBeGreaterThanOrEqual(1); + expect(stats.dbSizeBytes).toBeGreaterThan(0); + }); + }); + + // ===== Feature #9: Export/Import ===== + + describe('export/import', () => { + it('should export sessions with observations and prompts', async () => { + await service.initialize(); + await service.initSession('export-session', 'test-project', 'export task'); + await service.saveUserPrompt('export-session', 'test-project', 'export prompt'); + await service.storeObservation( + 'export-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + await service.completeSession('export-session', 'Done'); + + const data = await service.exportToJSON('test-project'); + expect(data.version).toBe('1.0'); + expect(data.project).toBe('test-project'); + expect(data.sessions.length).toBeGreaterThanOrEqual(1); + + const session = data.sessions.find(s => s.sessionId === 'export-session'); + expect(session).toBeDefined(); + expect(session!.observations.length).toBeGreaterThanOrEqual(1); + expect(session!.prompts.length).toBeGreaterThanOrEqual(1); + }); + + it('should export specific sessions by ID', async () => { + await service.initialize(); + await service.initSession('export-a', 'test-project', 'task A'); + await service.initSession('export-b', 'test-project', 'task B'); + + const data = await service.exportToJSON('test-project', ['export-a']); + expect(data.sessions.length).toBe(1); + expect(data.sessions[0].sessionId).toBe('export-a'); + }); + + it('should import exported data with new session IDs', async () => { + await service.initialize(); + await service.initSession('import-src', 'test-project', 'import task'); + await service.saveUserPrompt('import-src', 'test-project', 'import prompt'); + await service.storeObservation( + 'import-src', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + const exported = await service.exportToJSON('test-project', ['import-src']); + + // Importing into same DB: content_hash dedup will skip existing obs/prompts + // but session is always created new + const result = await service.importFromJSON(exported); + + expect(result.imported.sessions).toBe(1); + // Observations and prompts are deduplicated by content_hash since they already exist + expect(result.imported.observations + result.skipped.observations).toBeGreaterThanOrEqual(1); + expect(result.imported.prompts + result.skipped.prompts).toBeGreaterThanOrEqual(1); + }); + + it('should dedup observations by content_hash on reimport', async () => { + await service.initialize(); + await service.initSession('dedup-session', 'test-project', 'dedup task'); + await service.storeObservation( + 'dedup-session', 'test-project', 'Read', + { file_path: 'file.ts' }, { content: 'data' }, TEST_DIR + ); + + const exported = await service.exportToJSON('test-project', ['dedup-session']); + + // First import: content_hash already exists in same DB → skipped + const result1 = await service.importFromJSON(exported); + // Second import: still skipped (same hash) + const result2 = await service.importFromJSON(exported); + + // Both imports should have the session created but obs deduplicated + expect(result1.imported.sessions).toBe(1); + expect(result2.imported.sessions).toBe(1); + // Both skip observations since content_hash already in DB + expect(result1.skipped.observations + result2.skipped.observations).toBeGreaterThanOrEqual(1); + }); + }); }); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index dee4579..6e7f713 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -15,6 +15,8 @@ import { extractFilePaths, extractFacts, extractConcepts, + detectIntent, + extractIntents, truncate, parseHookInput, formatResponse, @@ -651,4 +653,94 @@ describe('Hook Types Utilities', () => { expect(concepts).toEqual([]); }); }); + + describe('detectIntent', () => { + it('should detect bugfix intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Fix the login bug'); + expect(intents).toContain('bugfix'); + }); + + it('should detect feature intent from prompt', () => { + const intents = detectIntent('Write', { file_path: 'src/new.ts' }, {}, 'Add a new payment feature'); + expect(intents).toContain('feature'); + }); + + it('should detect refactor intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Refactor the auth module'); + expect(intents).toContain('refactor'); + }); + + it('should detect testing intent from prompt', () => { + const intents = detectIntent('Bash', { command: 'vitest' }, {}, 'Run the tests'); + expect(intents).toContain('testing'); + }); + + it('should detect documentation intent from prompt', () => { + const intents = detectIntent('Write', { file_path: 'README.md' }, {}, 'Update the docs'); + expect(intents).toContain('documentation'); + }); + + it('should detect configuration intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'config.json' }, {}, 'Update config settings'); + expect(intents).toContain('configuration'); + }); + + it('should detect optimization intent from prompt', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.ts' }, {}, 'Optimize performance'); + expect(intents).toContain('optimization'); + }); + + it('should detect multiple intents', () => { + const intents = detectIntent('Edit', { file_path: 'src/app.test.ts' }, {}, 'Fix failing test'); + expect(intents).toContain('bugfix'); + expect(intents).toContain('testing'); + }); + + it('should default to investigation for read tools without prompt', () => { + const intents = detectIntent('Read', { file_path: 'src/app.ts' }, {}); + expect(intents).toContain('investigation'); + }); + + it('should detect testing from Bash test commands', () => { + const intents = detectIntent('Bash', { command: 'npx vitest run' }, {}); + expect(intents).toContain('testing'); + }); + + it('should detect testing from test file paths', () => { + const intents = detectIntent('Edit', { file_path: 'src/__tests__/app.test.ts' }, {}); + expect(intents).toContain('testing'); + }); + + it('should detect documentation from .md write', () => { + const intents = detectIntent('Write', { file_path: 'docs/guide.md' }, {}); + expect(intents).toContain('documentation'); + }); + + it('should detect configuration from config file writes', () => { + const intents = detectIntent('Edit', { file_path: 'tsconfig.json' }, {}); + expect(intents).toContain('configuration'); + }); + + it('should fallback to investigation when no signals found', () => { + const intents = detectIntent('Task', {}, {}); + expect(intents).toContain('investigation'); + }); + }); + + describe('extractIntents', () => { + it('should extract intent tags from concepts', () => { + const intents = extractIntents(['typescript', 'intent:bugfix', 'hooks', 'intent:testing']); + expect(intents).toEqual(['bugfix', 'testing']); + }); + + it('should return empty array when no intent tags', () => { + const intents = extractIntents(['typescript', 'hooks', 'api']); + expect(intents).toEqual([]); + }); + + it('should handle empty array', () => { + const intents = extractIntents([]); + expect(intents).toEqual([]); + }); + }); }); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index e8d04a1..e25520c 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -134,7 +134,7 @@ Return ONLY a JSON object (no markdown, no code fences) with these fields: "subtitle": "Brief context description (5-10 words, e.g. 'Examining authentication module')", "narrative": "One sentence explaining what happened and why (e.g. 'Read the authentication module to understand the login flow before making changes.')", "facts": ["Array of factual observations", "e.g. 'File auth.ts contains 150 lines'", "Max 5 facts"], - "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Max 5 concepts"] + "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Include 'intent:' tags for: bugfix, feature, refactor, testing, investigation, documentation, configuration, optimization", "Max 5 concepts"] }`; } diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index e33bc13..b065c09 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -72,7 +72,7 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session, lifecycle, lifecycle-stats, export, import'); process.exit(1); } @@ -159,6 +159,88 @@ async function main(): Promise { process.exit(0); } + // Handle 'lifecycle' command (no stdin, runs lifecycle tasks) + // Usage: lifecycle [--compress-days=7] [--archive-days=30] [--delete] [--delete-days=90] + if (event === 'lifecycle') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const config: Record = {}; + for (const arg of process.argv.slice(4)) { + if (arg.startsWith('--compress-days=')) config.compressAfterDays = parseInt(arg.split('=')[1], 10); + if (arg.startsWith('--archive-days=')) config.archiveAfterDays = parseInt(arg.split('=')[1], 10); + if (arg === '--delete') config.autoDelete = true; + if (arg.startsWith('--delete-days=')) { config.deleteAfterDays = parseInt(arg.split('=')[1], 10); config.autoDelete = true; } + } + const result = await svc.runLifecycleTasks(config); + console.log(JSON.stringify(result, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'lifecycle-stats' command + // Usage: lifecycle-stats + if (event === 'lifecycle-stats') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const stats = await svc.getLifecycleStats(); + console.log(JSON.stringify(stats, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'export' command + // Usage: export + if (event === 'export') { + const cwdArg = process.argv[3] || process.cwd(); + const project = process.argv[4]; + const outputPath = process.argv[5]; + if (!project || !outputPath) { + console.error('Usage: export '); + process.exit(1); + } + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const data = await svc.exportToJSON(project); + const { writeFileSync } = await import('node:fs'); + writeFileSync(outputPath, JSON.stringify(data, null, 2)); + console.error(`Exported ${data.sessions.length} sessions to ${outputPath}`); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + + // Handle 'import' command + // Usage: import + if (event === 'import') { + const cwdArg = process.argv[3] || process.cwd(); + const inputPath = process.argv[4]; + if (!inputPath) { + console.error('Usage: import '); + process.exit(1); + } + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const { readFileSync } = await import('node:fs'); + const data = JSON.parse(readFileSync(inputPath, 'utf-8')); + const result = await svc.importFromJSON(data); + console.log(JSON.stringify(result, null, 2)); + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 8957914..4ca9be9 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -26,10 +26,19 @@ import { extractFilePaths, extractFacts, extractConcepts, + detectIntent, + extractIntents, truncate, computeContentHash, ContextConfig, DEFAULT_CONTEXT_CONFIG, + LifecycleConfig, + DEFAULT_LIFECYCLE_CONFIG, + LifecycleResult, + LifecycleStats, + ExportData, + ExportSession, + ImportResult, } from './types.js'; import { enrichWithAI, enrichSummaryWithAI, compressObservationWithAI, generateSessionDigestWithAI } from './ai-enrichment.js'; @@ -219,6 +228,19 @@ export class MemoryHookService { }; } + /** + * Get the latest prompt text for a session (for intent detection) + */ + getLatestPromptText(sessionId: string): string | null { + if (!this.db) return null; + + const row = this.db.prepare( + 'SELECT prompt_text FROM user_prompts WHERE session_id = ? ORDER BY prompt_number DESC LIMIT 1' + ).get(sessionId) as { prompt_text: string } | undefined; + + return row?.prompt_text || null; + } + /** * Get current prompt number for a session (0 if no prompts yet) */ @@ -373,6 +395,13 @@ export class MemoryHookService { const facts = extractFacts(toolName, toolInput, toolResponse); const concepts = extractConcepts(toolName, toolInput, toolResponse); + // Detect developer intent and add as intent: prefixed tags + const latestPrompt = this.getLatestPromptText(sessionId); + const intents = detectIntent(toolName, toolInput, toolResponse, latestPrompt || undefined); + for (const intent of intents) { + concepts.push(`intent:${intent}`); + } + this.db!.prepare(` INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, prompt_number, files_read, files_modified, subtitle, narrative, facts, concepts, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -1012,7 +1041,8 @@ export class MemoryHookService { for (const obs of obsGroup) { const icon = this.getObservationIcon(obs.type); const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; - lines.push(`- ${icon} **${detail}** [${obs.id}]`); + const intentBadge = this.formatIntentBadge(obs.concepts || []); + lines.push(`- ${icon} **${detail}**${intentBadge} [${obs.id}]`); } lines.push(''); } @@ -1025,7 +1055,8 @@ export class MemoryHookService { const time = this.formatRelativeTime(obs.timestamp); const icon = this.getObservationIcon(obs.type); const detail = obs.compressedSummary || obs.subtitle || obs.title || obs.toolName; - lines.push(`- ${icon} **${detail}** (${time}) [${obs.id}]`); + const intentBadge = this.formatIntentBadge(obs.concepts || []); + lines.push(`- ${icon} **${detail}**${intentBadge} (${time}) [${obs.id}]`); if (obs.narrative) { lines.push(` ${obs.narrative}`); } @@ -1278,6 +1309,313 @@ export class MemoryHookService { }; } + // ===== Lifecycle Management ===== + + /** + * Run lifecycle tasks: compress old observations, archive old sessions, + * optionally delete archived sessions, and vacuum. + */ + async runLifecycleTasks(config: Partial = {}): Promise { + await this.ensureInitialized(); + + const cfg = { ...DEFAULT_LIFECYCLE_CONFIG, ...config }; + const now = Date.now(); + let compressed = 0; + let archived = 0; + let deleted = 0; + let vacuumed = false; + + // 1. Compress old uncompressed observations + if (cfg.autoCompress) { + const cutoff = now - cfg.compressAfterDays * 86400000; + const rows = this.db!.prepare( + 'SELECT id FROM observations WHERE is_compressed = 0 AND timestamp < ? LIMIT 100' + ).all(cutoff) as { id: string }[]; + + for (const row of rows) { + this.queueTask('compress', 'observations', row.id); + compressed++; + } + } + + // 2. Archive old completed sessions + if (cfg.autoArchive) { + const cutoff = now - cfg.archiveAfterDays * 86400000; + const result = this.db!.prepare( + "UPDATE sessions SET status = 'archived' WHERE status = 'completed' AND ended_at IS NOT NULL AND ended_at < ?" + ).run(cutoff); + archived = result.changes; + } + + // 3. Delete archived sessions (opt-in) + if (cfg.autoDelete) { + const cutoff = now - cfg.deleteAfterDays * 86400000; + const sessions = this.db!.prepare( + "SELECT session_id FROM sessions WHERE status = 'archived' AND ended_at IS NOT NULL AND ended_at < ?" + ).all(cutoff) as { session_id: string }[]; + + if (sessions.length > 0) { + const deleteTransaction = this.db!.transaction(() => { + for (const s of sessions) { + this.db!.prepare('DELETE FROM observations WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM user_prompts WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM session_summaries WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM session_digests WHERE session_id = ?').run(s.session_id); + this.db!.prepare('DELETE FROM sessions WHERE session_id = ?').run(s.session_id); + } + }); + deleteTransaction(); + deleted = sessions.length; + + // 4. Vacuum if deletes occurred + if (cfg.autoVacuum && deleted > 0) { + this.db!.exec('VACUUM'); + vacuumed = true; + } + } + } + + return { compressed, archived, deleted, vacuumed }; + } + + /** + * Get lifecycle statistics for the database + */ + async getLifecycleStats(): Promise { + await this.ensureInitialized(); + + const sessionStats = this.db!.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) as archived + FROM sessions + `).get() as { total: number; active: number; completed: number; archived: number }; + + const obsStats = this.db!.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN is_compressed = 1 THEN 1 ELSE 0 END) as compressed, + SUM(CASE WHEN is_compressed = 0 THEN 1 ELSE 0 END) as uncompressed + FROM observations + `).get() as { total: number; compressed: number; uncompressed: number }; + + const promptStats = this.db!.prepare( + 'SELECT COUNT(*) as total FROM user_prompts' + ).get() as { total: number }; + + // Get DB file size + let dbSizeBytes = 0; + try { + const { statSync } = await import('node:fs'); + const stat = statSync(this.dbPath); + dbSizeBytes = stat.size; + } catch { /* ignore */ } + + return { + totalSessions: sessionStats.total || 0, + activeSessions: sessionStats.active || 0, + completedSessions: sessionStats.completed || 0, + archivedSessions: sessionStats.archived || 0, + totalObservations: obsStats.total || 0, + compressedObservations: obsStats.compressed || 0, + uncompressedObservations: obsStats.uncompressed || 0, + totalPrompts: promptStats.total || 0, + dbSizeBytes, + }; + } + + // ===== Export/Import ===== + + /** + * Export sessions and related data to JSON format + */ + async exportToJSON(project: string, sessionIds?: string[]): Promise { + await this.ensureInitialized(); + + let sessions: Record[]; + if (sessionIds && sessionIds.length > 0) { + const placeholders = sessionIds.map(() => '?').join(','); + sessions = this.db!.prepare( + `SELECT * FROM sessions WHERE session_id IN (${placeholders})` + ).all(...sessionIds) as Record[]; + } else { + sessions = this.db!.prepare( + 'SELECT * FROM sessions WHERE project = ? ORDER BY started_at DESC' + ).all(project) as Record[]; + } + + const exportSessions: ExportSession[] = []; + + for (const session of sessions) { + const sid = session.session_id as string; + + const observations = this.db!.prepare( + 'SELECT * FROM observations WHERE session_id = ? ORDER BY timestamp ASC' + ).all(sid) as Record[]; + + const prompts = this.db!.prepare( + 'SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC' + ).all(sid) as Record[]; + + const summary = this.db!.prepare( + 'SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1' + ).get(sid) as Record | undefined; + + exportSessions.push({ + sessionId: sid, + project: session.project as string, + prompt: session.prompt as string, + startedAt: session.started_at as number, + endedAt: (session.ended_at as number) || undefined, + status: session.status as string, + parentSessionId: (session.parent_session_id as string) || undefined, + observations: observations.map(o => ({ + id: o.id as string, + toolName: o.tool_name as string, + timestamp: o.timestamp as number, + type: o.type as string, + title: o.title as string | undefined, + subtitle: o.subtitle as string | undefined, + narrative: o.narrative as string | undefined, + facts: JSON.parse((o.facts as string) || '[]'), + concepts: JSON.parse((o.concepts as string) || '[]'), + contentHash: o.content_hash as string | undefined, + compressedSummary: o.compressed_summary as string | undefined, + isCompressed: (o.is_compressed as number) === 1, + })), + prompts: prompts.map(p => ({ + promptNumber: p.prompt_number as number, + promptText: p.prompt_text as string, + createdAt: p.created_at as number, + contentHash: p.content_hash as string | undefined, + })), + summary: summary ? { + request: summary.request as string, + completed: summary.completed as string, + filesRead: JSON.parse((summary.files_read as string) || '[]'), + filesModified: JSON.parse((summary.files_modified as string) || '[]'), + nextSteps: summary.next_steps as string, + notes: summary.notes as string, + } : undefined, + }); + } + + return { + version: '1.0', + exportedAt: Date.now(), + project, + sessions: exportSessions, + }; + } + + /** + * Import sessions and related data from JSON format. + * Generates new session IDs prefixed with 'imported_' to avoid conflicts. + * Deduplicates observations and prompts via content_hash. + */ + async importFromJSON(data: ExportData): Promise { + await this.ensureInitialized(); + + let importedSessions = 0; + let importedObservations = 0; + let importedPrompts = 0; + let skippedObservations = 0; + let skippedPrompts = 0; + + const importTransaction = this.db!.transaction(() => { + for (const session of data.sessions) { + const newSessionId = `imported_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; + + // Insert session + this.db!.prepare(` + INSERT INTO sessions (session_id, project, prompt, started_at, ended_at, observation_count, status, parent_session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newSessionId, session.project, session.prompt, + session.startedAt, session.endedAt || null, + session.observations.length, session.status || 'completed', + session.parentSessionId || null + ); + importedSessions++; + + // Import observations (dedup by content_hash) + for (const obs of session.observations) { + if (obs.contentHash) { + const existing = this.db!.prepare( + 'SELECT id FROM observations WHERE content_hash = ? LIMIT 1' + ).get(obs.contentHash) as { id: string } | undefined; + if (existing) { + skippedObservations++; + continue; + } + } + + const newObsId = generateObservationId(); + this.db!.prepare(` + INSERT INTO observations (id, session_id, project, tool_name, tool_input, tool_response, cwd, timestamp, type, title, subtitle, narrative, facts, concepts, content_hash, compressed_summary, is_compressed) + VALUES (?, ?, ?, ?, '{}', '{}', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newObsId, newSessionId, session.project, obs.toolName, + obs.timestamp, obs.type, obs.title || null, obs.subtitle || null, + obs.narrative || null, JSON.stringify(obs.facts || []), + JSON.stringify(obs.concepts || []), obs.contentHash || null, + obs.compressedSummary || null, obs.isCompressed ? 1 : 0 + ); + importedObservations++; + + // Queue embedding + this.queueTask('embed', 'observations', newObsId); + } + + // Import prompts (dedup by content_hash) + for (const prompt of session.prompts) { + if (prompt.contentHash) { + const fiveMinWindow = prompt.createdAt + 5 * 60 * 1000; + const existing = this.db!.prepare( + 'SELECT id FROM user_prompts WHERE content_hash = ? AND created_at < ? LIMIT 1' + ).get(prompt.contentHash, fiveMinWindow) as { id: number } | undefined; + if (existing) { + skippedPrompts++; + continue; + } + } + + const result = this.db!.prepare(` + INSERT INTO user_prompts (session_id, prompt_number, prompt_text, content_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(newSessionId, prompt.promptNumber, prompt.promptText, prompt.contentHash || null, prompt.createdAt); + importedPrompts++; + + if (result.changes > 0) { + this.queueTask('embed', 'user_prompts', Number(result.lastInsertRowid)); + } + } + + // Import summary + if (session.summary) { + const s = session.summary; + this.db!.prepare(` + INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newSessionId, session.project, s.request, s.completed, + JSON.stringify(s.filesRead || []), JSON.stringify(s.filesModified || []), + s.nextSteps || '', s.notes || '', session.prompts.length, Date.now() + ); + } + } + }); + + importTransaction(); + + return { + imported: { sessions: importedSessions, observations: importedObservations, prompts: importedPrompts }, + skipped: { observations: skippedObservations, prompts: skippedPrompts }, + }; + } + // ===== Private Methods ===== private async ensureInitialized(): Promise { @@ -1523,6 +1861,12 @@ export class MemoryHookService { return new Date(timestamp).toLocaleDateString(); } + private formatIntentBadge(concepts: string[]): string { + const intents = extractIntents(concepts); + if (intents.length === 0) return ''; + return ` [${intents.join(', ')}]`; + } + private getObservationIcon(type: string): string { switch (type) { case 'read': return '📖'; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 7b0a92f..932eefd 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -207,6 +207,131 @@ export type ObservationType = | 'search' // WebSearch, WebFetch | 'other'; // Unknown tools +/** + * Observation intent — what the developer is trying to accomplish. + * Stored as `intent:` prefixed tags in the concepts array (no schema change). + */ +export type ObservationIntent = + | 'bugfix' + | 'feature' + | 'refactor' + | 'investigation' + | 'testing' + | 'documentation' + | 'configuration' + | 'optimization'; + +/** + * Detect the developer's intent from tool usage context. + * Pattern-matches on prompt text, tool name, and tool input. + * Returns one or more intents (usually 1-2). + */ +export function detectIntent( + toolName: string, + toolInput: unknown, + _toolResponse: unknown, + prompt?: string +): ObservationIntent[] { + const intents: Set = new Set(); + + // Pattern-match on latest prompt text (strongest signal) + if (prompt) { + const p = prompt.toLowerCase(); + + // Bugfix signals + if (/\b(fix|bug|broken|crash|error|issue|wrong|fail|regress|patch|hotfix)\b/.test(p)) { + intents.add('bugfix'); + } + // Feature signals + if (/\b(add|create|implement|new|feature|build|introduce|enable)\b/.test(p)) { + intents.add('feature'); + } + // Refactor signals + if (/\b(refactor|rename|restructure|reorganize|clean\s*up|simplify|extract|move|split|merge|dedup)\b/.test(p)) { + intents.add('refactor'); + } + // Testing signals + if (/\b(test|spec|coverage|assert|expect|mock|stub|vitest|jest|pytest)\b/.test(p)) { + intents.add('testing'); + } + // Documentation signals + if (/\b(doc|readme|comment|jsdoc|typedoc|changelog|annotation)\b/.test(p)) { + intents.add('documentation'); + } + // Configuration signals + if (/\b(config|setting|env|environment|setup|install|dependency|package|deploy)\b/.test(p)) { + intents.add('configuration'); + } + // Optimization signals + if (/\b(optimiz\w*|perf\w*|speed|slow|fast\w*|cach\w*|lazy|memory\s*leak|bundl\w*|minif\w*|compress\w*)\b/.test(p)) { + intents.add('optimization'); + } + } + + // Pattern-match on tool name (secondary signal) + const readTools = ['Read', 'Glob', 'Grep', 'LS']; + const writeTools = ['Write', 'Edit', 'MultiEdit']; + const searchTools = ['WebSearch', 'WebFetch']; + + if (readTools.includes(toolName) || searchTools.includes(toolName)) { + // Reading/searching without other intent → investigation + if (intents.size === 0) { + intents.add('investigation'); + } + } + + // Pattern-match on tool input (tertiary signal) + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + + if (toolName === 'Bash') { + const cmd = ((input?.command as string) || '').toLowerCase(); + if (/\b(test|vitest|jest|pytest|mocha|tap)\b/.test(cmd)) { + intents.add('testing'); + } + if (/\b(build|tsc|webpack|vite|esbuild|rollup)\b/.test(cmd)) { + if (intents.size === 0) intents.add('feature'); + } + if (/\b(lint|eslint|prettier|format)\b/.test(cmd)) { + intents.add('refactor'); + } + } + + // File path hints + const filePath = ((input?.file_path || input?.path || '') as string).toLowerCase(); + if (filePath) { + if (/\.(test|spec)\.(ts|js|tsx|jsx)$/.test(filePath) || filePath.includes('__tests__')) { + intents.add('testing'); + } + if (/readme|changelog|\.md$/.test(filePath) && writeTools.includes(toolName)) { + intents.add('documentation'); + } + if (/config|\.env|tsconfig|package\.json|\.eslintrc/.test(filePath) && writeTools.includes(toolName)) { + intents.add('configuration'); + } + } + } catch { + // Ignore parse errors + } + + // Fallback: if no intent detected, default to investigation + if (intents.size === 0) { + intents.add('investigation'); + } + + return Array.from(intents); +} + +/** + * Extract intent tags from a concepts array. + * Filters concepts starting with 'intent:' and strips the prefix. + */ +export function extractIntents(concepts: string[]): ObservationIntent[] { + return concepts + .filter(c => c.startsWith('intent:')) + .map(c => c.slice(7) as ObservationIntent); +} + /** * Session record for tracking */ @@ -331,6 +456,82 @@ export interface MemoryContext { markdown: string; } +// ===== Export/Import Types ===== + +/** + * Export data format + */ +export interface ExportData { + version: string; + exportedAt: number; + project: string; + sessions: ExportSession[]; +} + +/** + * Exported session with all related data + */ +export interface ExportSession { + sessionId: string; + project: string; + prompt: string; + startedAt: number; + endedAt?: number; + status: string; + parentSessionId?: string; + observations: ExportObservation[]; + prompts: ExportPrompt[]; + summary?: ExportSummary; +} + +/** + * Exported observation + */ +export interface ExportObservation { + id: string; + toolName: string; + timestamp: number; + type: string; + title?: string; + subtitle?: string; + narrative?: string; + facts: string[]; + concepts: string[]; + contentHash?: string; + compressedSummary?: string; + isCompressed: boolean; +} + +/** + * Exported user prompt + */ +export interface ExportPrompt { + promptNumber: number; + promptText: string; + createdAt: number; + contentHash?: string; +} + +/** + * Exported session summary + */ +export interface ExportSummary { + request: string; + completed: string; + filesRead: string[]; + filesModified: string[]; + nextSteps: string; + notes: string; +} + +/** + * Import result + */ +export interface ImportResult { + imported: { sessions: number; observations: number; prompts: number }; + skipped: { observations: number; prompts: number }; +} + // ===== Utility Functions ===== /** @@ -346,6 +547,62 @@ export interface ContextConfig { maxObservations: number; } +/** + * Lifecycle configuration for memory decay/archival + */ +export interface LifecycleConfig { + /** Auto-compress old observations */ + autoCompress: boolean; + /** Days after which to compress observations */ + compressAfterDays: number; + /** Auto-archive old sessions */ + autoArchive: boolean; + /** Days after which to archive sessions */ + archiveAfterDays: number; + /** Auto-delete archived sessions (opt-in, disabled by default) */ + autoDelete: boolean; + /** Days after which to delete archived sessions */ + deleteAfterDays: number; + /** Auto-vacuum after deletes */ + autoVacuum: boolean; +} + +/** Default lifecycle configuration */ +export const DEFAULT_LIFECYCLE_CONFIG: LifecycleConfig = { + autoCompress: true, + compressAfterDays: 7, + autoArchive: true, + archiveAfterDays: 30, + autoDelete: false, // opt-in: destructive + deleteAfterDays: 90, + autoVacuum: true, +}; + +/** + * Lifecycle task results + */ +export interface LifecycleResult { + compressed: number; + archived: number; + deleted: number; + vacuumed: boolean; +} + +/** + * Lifecycle statistics + */ +export interface LifecycleStats { + totalSessions: number; + activeSessions: number; + completedSessions: number; + archivedSessions: number; + totalObservations: number; + compressedObservations: number; + uncompressedObservations: number; + totalPrompts: number; + dbSizeBytes: number; +} + /** Default context configuration */ export const DEFAULT_CONTEXT_CONFIG: ContextConfig = { showSummaries: true, From e0d2d7d41144190f3c7705ddd0bedfe197d10f08 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 08:56:38 +0900 Subject: [PATCH 09/21] feat: enhance AI enrichment and memory service with decision tracking and structured diffs --- src/hooks/__tests__/ai-enrichment.test.ts | 50 ++++++++ src/hooks/__tests__/service.test.ts | 143 ++++++++++++++++++++++ src/hooks/__tests__/types.test.ts | 140 ++++++++++++++++++++- src/hooks/ai-enrichment.ts | 14 ++- src/hooks/service.ts | 59 +++++++-- src/hooks/types.ts | 99 ++++++++++++++- 6 files changed, 489 insertions(+), 16 deletions(-) diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index eedd59a..1a2d725 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -569,6 +569,50 @@ describe('AI Enrichment Module', () => { it('should return null for invalid JSON', () => { expect(parseSummaryResponse('not json')).toBeNull(); }); + + it('should parse decisions array', () => { + const json = JSON.stringify({ + completed: 'Fixed the bug.', + nextSteps: 'None', + decisions: ['Used mutex for thread safety', 'Chose retry pattern over circuit breaker'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toHaveLength(2); + expect(result!.decisions[0]).toBe('Used mutex for thread safety'); + }); + + it('should default to empty decisions when not provided', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toEqual([]); + }); + + it('should cap decisions at 5 items', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + decisions: Array.from({ length: 10 }, (_, i) => `Decision ${i}`), + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toHaveLength(5); + }); + + it('should filter out non-string decisions', () => { + const json = JSON.stringify({ + completed: 'Done.', + nextSteps: 'None', + decisions: ['Valid', 123, null, '', 'Also valid'], + }); + const result = parseSummaryResponse(json); + expect(result).not.toBeNull(); + expect(result!.decisions).toEqual(['Valid', 'Also valid']); + }); }); describe('buildSummaryPrompt', () => { @@ -587,6 +631,12 @@ describe('AI Enrichment Module', () => { // Should contain truncated versions (3000 chars each) expect(prompt.length).toBeLessThan(10000); }); + + it('should ask for decisions in prompt', () => { + const prompt = buildSummaryPrompt('Request: Fix auth', 'Fixed the auth flow.'); + expect(prompt).toContain('decisions'); + expect(prompt).toContain('WHY'); + }); }); describe('enrichSummaryWithAI with mock CLI', () => { diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 9335823..9ea6386 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -1118,6 +1118,149 @@ describe('MemoryHookService', () => { // ===== Feature #9: Export/Import ===== + describe('structured diff capture', () => { + it('should include diff facts for Edit observations', async () => { + await service.initialize(); + await service.initSession('diff-session', 'test-project'); + const obs = await service.storeObservation( + 'diff-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + expect(obs.facts).toBeDefined(); + expect(obs.facts!.some(f => f.includes('DIFF'))).toBe(true); + expect(obs.facts!.some(f => f.includes('function login(user) {'))).toBe(true); + expect(obs.facts!.some(f => f.includes('function login(user, opts) {'))).toBe(true); + }); + + it('should include diff info in narrative for Edit', async () => { + await service.initialize(); + await service.initSession('diff-session-2', 'test-project'); + const obs = await service.storeObservation( + 'diff-session-2', 'test-project', 'Edit', + { file_path: 'src/app.ts', old_string: 'const x = 1;', new_string: 'const x = 2;' }, + {}, TEST_DIR + ); + + expect(obs.narrative).toBeDefined(); + expect(obs.narrative).toContain('const x = 1;'); + expect(obs.narrative).toContain('const x = 2;'); + }); + + it('should handle MultiEdit with multiple diffs', async () => { + await service.initialize(); + await service.initSession('multi-diff', 'test-project'); + const obs = await service.storeObservation( + 'multi-diff', 'test-project', 'MultiEdit', + { + file_path: 'src/index.ts', + edits: [ + { old_string: 'import { a } from "./a"', new_string: 'import { a, b } from "./a"' }, + { old_string: 'export default a;', new_string: 'export default { a, b };' }, + ], + }, + {}, TEST_DIR + ); + + expect(obs.facts!.filter(f => f.includes('DIFF')).length).toBe(2); + }); + }); + + describe('decision rationale in summaries', () => { + it('should extract decisions from Edit observations', async () => { + await service.initialize(); + await service.initSession('decision-session', 'test-project'); + await service.saveUserPrompt('decision-session', 'test-project', 'Fix the auth bug'); + + await service.storeObservation( + 'decision-session', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('decision-session'); + + expect(summary.decisions).toBeDefined(); + expect(summary.decisions.length).toBeGreaterThan(0); + expect(summary.decisions[0]).toContain('auth.ts'); + expect(summary.decisions[0]).toContain('function login'); + }); + + it('should include intent tags in decisions', async () => { + await service.initialize(); + await service.initSession('intent-decision', 'test-project'); + await service.saveUserPrompt('intent-decision', 'test-project', 'Refactor the handler'); + + await service.storeObservation( + 'intent-decision', 'test-project', 'Edit', + { file_path: 'src/handler.ts', old_string: 'async handle(req)', new_string: 'async handleRequest(req, res)' }, + {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('intent-decision'); + + expect(summary.decisions.length).toBeGreaterThan(0); + expect(summary.decisions[0]).toContain('refactor'); + }); + + it('should include decisions in saved session summaries', async () => { + await service.initialize(); + await service.initSession('saved-decision', 'test-project'); + await service.saveUserPrompt('saved-decision', 'test-project', 'Add feature'); + + await service.storeObservation( + 'saved-decision', 'test-project', 'Edit', + { file_path: 'src/feature.ts', old_string: 'const x = 1;', new_string: 'const x = getValue();' }, + {}, TEST_DIR + ); + + const structured = await service.generateStructuredSummary('saved-decision'); + const saved = await service.saveSessionSummary(structured); + + expect(saved.decisions).toBeDefined(); + expect(saved.decisions.length).toBeGreaterThan(0); + + // Verify it roundtrips through DB + const summaries = await service.getRecentSummaries('test-project'); + const found = summaries.find(s => s.sessionId === 'saved-decision'); + expect(found).toBeDefined(); + expect(found!.decisions.length).toBeGreaterThan(0); + }); + + it('should return empty decisions when no Edit observations', async () => { + await service.initialize(); + await service.initSession('no-decision', 'test-project'); + await service.storeObservation( + 'no-decision', 'test-project', 'Read', + { file_path: 'src/app.ts' }, {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('no-decision'); + + expect(summary.decisions).toEqual([]); + }); + + it('should show decisions in context markdown', async () => { + await service.initialize(); + await service.initSession('ctx-decision', 'test-project'); + await service.saveUserPrompt('ctx-decision', 'test-project', 'Fix bug'); + + await service.storeObservation( + 'ctx-decision', 'test-project', 'Edit', + { file_path: 'src/fix.ts', old_string: 'return null;', new_string: 'return defaultValue;' }, + {}, TEST_DIR + ); + + const structured = await service.generateStructuredSummary('ctx-decision'); + await service.saveSessionSummary(structured); + await service.completeSession('ctx-decision', 'Done'); + + const ctx = await service.getContext('test-project'); + expect(ctx.markdown).toContain('Decisions'); + }); + }); + describe('export/import', () => { it('should export sessions with observations and prompts', async () => { await service.initialize(); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index 6e7f713..af06850 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -15,6 +15,8 @@ import { extractFilePaths, extractFacts, extractConcepts, + extractCodeDiffs, + formatDiffFact, detectIntent, extractIntents, truncate, @@ -535,11 +537,13 @@ describe('Hook Types Utilities', () => { expect(facts).toContain('File created/updated: /src/new.ts'); }); - it('should extract facts from Edit', () => { - const facts = extractFacts('Edit', { file_path: '/src/index.ts', old_string: 'old code' }, {}); + it('should extract facts from Edit with structured diff', () => { + const facts = extractFacts('Edit', { file_path: '/src/index.ts', old_string: 'old code', new_string: 'new code' }, {}); expect(facts.length).toBe(2); expect(facts[0]).toContain('/src/index.ts'); - expect(facts[1]).toContain('replaced'); + expect(facts[1]).toContain('DIFF'); + expect(facts[1]).toContain('old code'); + expect(facts[1]).toContain('new code'); }); it('should extract facts from Bash with test results', () => { @@ -595,7 +599,20 @@ describe('Hook Types Utilities', () => { expect(facts).toContain('Errors encountered'); }); - it('should handle MultiEdit like Edit', () => { + it('should handle MultiEdit with edits array', () => { + const facts = extractFacts('MultiEdit', { + file_path: 'app.ts', + edits: [ + { old_string: 'const x = 1', new_string: 'const x = 2' }, + { old_string: 'let y = 3', new_string: 'let y = 4' }, + ], + }, {}); + expect(facts).toContain('File modified: app.ts'); + expect(facts.some(f => f.includes('DIFF'))).toBe(true); + expect(facts.some(f => f.includes('const x = 1'))).toBe(true); + }); + + it('should fallback to Code replaced for MultiEdit without edits array', () => { const facts = extractFacts('MultiEdit', { file_path: 'app.ts', old_string: 'old' }, {}); expect(facts).toContain('File modified: app.ts'); expect(facts.some(f => f.includes('Code replaced'))).toBe(true); @@ -743,4 +760,119 @@ describe('Hook Types Utilities', () => { expect(intents).toEqual([]); }); }); + + describe('extractCodeDiffs', () => { + it('should extract diff from Edit tool', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'src/auth.ts', + old_string: 'function login(user) {', + new_string: 'function login(user, opts) {', + }); + expect(diffs).toHaveLength(1); + expect(diffs[0].file).toBe('src/auth.ts'); + expect(diffs[0].before).toBe('function login(user) {'); + expect(diffs[0].after).toBe('function login(user, opts) {'); + }); + + it('should extract multiple diffs from MultiEdit', () => { + const diffs = extractCodeDiffs('MultiEdit', { + file_path: 'src/app.ts', + edits: [ + { old_string: 'const a = 1;', new_string: 'const a = 2;' }, + { old_string: 'let b = true;', new_string: 'let b = false;' }, + ], + }); + expect(diffs).toHaveLength(2); + expect(diffs[0].before).toBe('const a = 1;'); + expect(diffs[1].before).toBe('let b = true;'); + }); + + it('should return empty array for non-Edit tools', () => { + expect(extractCodeDiffs('Read', { file_path: 'a.ts' })).toEqual([]); + expect(extractCodeDiffs('Bash', { command: 'test' })).toEqual([]); + expect(extractCodeDiffs('Write', { file_path: 'a.ts' })).toEqual([]); + }); + + it('should truncate long strings to 200 chars', () => { + const longStr = 'x'.repeat(300); + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: longStr, + new_string: 'short', + }); + expect(diffs[0].before.length).toBe(200); + expect(diffs[0].after).toBe('short'); + }); + + it('should calculate changeLines correctly', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'line1', + new_string: 'line1\nline2\nline3', + }); + expect(diffs[0].changeLines).toBe(2); // 3 lines - 1 line = +2 + }); + + it('should cap MultiEdit at 5 edits', () => { + const edits = Array.from({ length: 10 }, (_, i) => ({ + old_string: `old${i}`, + new_string: `new${i}`, + })); + const diffs = extractCodeDiffs('MultiEdit', { file_path: 'a.ts', edits }); + expect(diffs).toHaveLength(5); + }); + + it('should handle JSON string input', () => { + const diffs = extractCodeDiffs('Edit', JSON.stringify({ + file_path: 'src/app.ts', + old_string: 'before', + new_string: 'after', + })); + expect(diffs).toHaveLength(1); + expect(diffs[0].before).toBe('before'); + }); + + it('should handle missing old_string or new_string', () => { + const diffs = extractCodeDiffs('Edit', { file_path: 'a.ts' }); + expect(diffs).toEqual([]); + }); + }); + + describe('formatDiffFact', () => { + it('should format diff as compact fact string', () => { + const fact = formatDiffFact({ + file: 'src/auth.ts', + before: 'function login(user) {', + after: 'function login(user, opts) {', + changeLines: 0, + }); + expect(fact).toContain('DIFF'); + expect(fact).toContain('auth.ts'); + expect(fact).toContain('function login(user) {'); + expect(fact).toContain('function login(user, opts) {'); + }); + + it('should use filename only (not full path)', () => { + const fact = formatDiffFact({ + file: '/very/long/path/to/file.ts', + before: 'old', + after: 'new', + changeLines: 0, + }); + expect(fact).toContain('file.ts'); + expect(fact).not.toContain('/very/long'); + }); + + it('should truncate long first lines to 60 chars', () => { + const longLine = 'x'.repeat(100); + const fact = formatDiffFact({ + file: 'a.ts', + before: longLine, + after: 'short', + changeLines: 0, + }); + // First line is truncated to 60 chars + expect(fact.length).toBeLessThan(200); + }); + }); }); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index e25520c..b9928e1 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -249,6 +249,7 @@ export function _setRunClaudePrintMockForTesting( export interface EnrichedSummary { completed: string; nextSteps: string; + decisions: string[]; } /** @@ -269,7 +270,8 @@ ${lastAssistantMessage.substring(0, 3000)} Return ONLY a JSON object (no markdown, no code fences) with these fields: { "completed": "Concise paragraph describing what was actually completed (2-4 sentences). Merge info from both the template summary and the assistant's final message.", - "nextSteps": "Concise list of remaining work or follow-up items, if any. Use 'None' if everything was completed." + "nextSteps": "Concise list of remaining work or follow-up items, if any. Use 'None' if everything was completed.", + "decisions": ["Array of key decision rationales — WHY specific changes were made, not just WHAT changed. E.g. 'Used mutex for token refresh to prevent race condition'. Max 5 decisions. Empty array if no clear decisions."] }`; } @@ -300,9 +302,19 @@ export function parseSummaryResponse(text: string): EnrichedSummary | null { nextSteps = 'None'; } + // Handle decisions: accept array or empty + let decisions: string[] = []; + if (Array.isArray(parsed.decisions)) { + decisions = parsed.decisions + .filter((d: unknown) => typeof d === 'string' && d.length > 0) + .slice(0, 5) + .map((d: unknown) => String(d).substring(0, 200)); + } + return { completed: completed.substring(0, 1000), nextSteps: nextSteps.substring(0, 500), + decisions, }; } catch { return null; diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 4ca9be9..1ef447a 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -28,6 +28,8 @@ import { extractConcepts, detectIntent, extractIntents, + extractCodeDiffs, + formatDiffFact, truncate, computeContentHash, ContextConfig, @@ -992,6 +994,9 @@ export class MemoryHookService { if (summary.filesModified.length > 0) { lines.push(`**Files Modified:** ${summary.filesModified.slice(0, 10).join(', ')}`); } + if (summary.decisions && summary.decisions.length > 0) { + lines.push(`**Decisions:** ${summary.decisions.slice(0, 5).join('; ')}`); + } if (summary.nextSteps) { lines.push(`**Next Steps:** ${summary.nextSteps}`); } @@ -1137,10 +1142,11 @@ export class MemoryHookService { const prompts = await this.getSessionPrompts(sessionId); const session = this.getSession(sessionId); - // Extract file paths from observations + // Extract file paths, commands, and decisions from observations const filesRead: Set = new Set(); const filesModified: Set = new Set(); const commands: string[] = []; + const decisions: string[] = []; for (const obs of observations) { try { @@ -1151,6 +1157,22 @@ export class MemoryHookService { filesRead.add(filePath); } else if (obs.type === 'write' && filePath) { filesModified.add(filePath); + + // Extract decision rationale from Edit/MultiEdit diffs + if (obs.toolName === 'Edit' || obs.toolName === 'MultiEdit') { + const diffs = extractCodeDiffs(obs.toolName, input); + const intents = extractIntents(obs.concepts || []); + const intentLabel = intents.length > 0 ? ` (${intents.join(', ')})` : ''; + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + for (const diff of diffs.slice(0, 2)) { + const beforeLine = diff.before.split('\n')[0].trim().substring(0, 40); + const afterLine = diff.after.split('\n')[0].trim().substring(0, 40); + if (beforeLine && afterLine && beforeLine !== afterLine) { + decisions.push(`${fileName}${intentLabel}: "${beforeLine}" → "${afterLine}"`); + } + } + } } else if (obs.type === 'execute' && input.command) { commands.push(input.command.substring(0, 80)); } @@ -1189,6 +1211,7 @@ export class MemoryHookService { filesModified: Array.from(filesModified).slice(0, 20), nextSteps: '', notes, + decisions: decisions.slice(0, 10), promptNumber: prompts.length, }; } @@ -1204,8 +1227,8 @@ export class MemoryHookService { const now = Date.now(); const result = this.db!.prepare(` INSERT INTO session_summaries - (session_id, project, request, completed, files_read, files_modified, next_steps, notes, prompt_number, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( summary.sessionId, summary.project, @@ -1215,6 +1238,7 @@ export class MemoryHookService { JSON.stringify(summary.filesModified), summary.nextSteps, summary.notes, + JSON.stringify(summary.decisions || []), summary.promptNumber, now ); @@ -1283,12 +1307,17 @@ export class MemoryHookService { const enriched = await enrichSummaryWithAI(templateText, lastMessage).catch(() => null); if (!enriched) return false; - // Update summary in-place + // Update summary in-place (including AI-extracted decisions if available) this.db!.prepare(` UPDATE session_summaries - SET completed = ?, next_steps = ? + SET completed = ?, next_steps = ?, decisions = ? WHERE id = ? - `).run(enriched.completed, enriched.nextSteps, summary.id); + `).run( + enriched.completed, + enriched.nextSteps, + JSON.stringify(enriched.decisions || summary.decisions || []), + summary.id + ); return true; } @@ -1304,6 +1333,7 @@ export class MemoryHookService { filesModified: JSON.parse((row.files_modified as string) || '[]'), nextSteps: row.next_steps as string || '', notes: row.notes as string || '', + decisions: JSON.parse((row.decisions as string) || '[]'), promptNumber: row.prompt_number as number || 0, createdAt: row.created_at as number, }; @@ -1498,6 +1528,7 @@ export class MemoryHookService { filesModified: JSON.parse((summary.files_modified as string) || '[]'), nextSteps: summary.next_steps as string, notes: summary.notes as string, + decisions: JSON.parse((summary.decisions as string) || '[]'), } : undefined, }); } @@ -1597,12 +1628,13 @@ export class MemoryHookService { if (session.summary) { const s = session.summary; this.db!.prepare(` - INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, prompt_number, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( newSessionId, session.project, s.request, s.completed, JSON.stringify(s.filesRead || []), JSON.stringify(s.filesModified || []), - s.nextSteps || '', s.notes || '', session.prompts.length, Date.now() + s.nextSteps || '', s.notes || '', JSON.stringify(s.decisions || []), + session.prompts.length, Date.now() ); } } @@ -1696,6 +1728,7 @@ export class MemoryHookService { files_modified TEXT DEFAULT '[]', next_steps TEXT, notes TEXT, + decisions TEXT DEFAULT '[]', prompt_number INTEGER, created_at INTEGER NOT NULL, embedding BLOB, @@ -1793,6 +1826,14 @@ export class MemoryHookService { } } catch { /* ignore */ } + // Migrate session_summaries: add decisions column + try { + const summaryCols = this.db.prepare("PRAGMA table_info(session_summaries)").all() as Array<{ name: string }>; + if (!summaryCols.some(c => c.name === 'decisions')) { + this.db.exec("ALTER TABLE session_summaries ADD COLUMN decisions TEXT DEFAULT '[]'"); + } + } catch { /* ignore */ } + // Add embedding column to all session tables for (const table of ['observations', 'user_prompts', 'session_summaries']) { const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 932eefd..c3de686 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -332,6 +332,80 @@ export function extractIntents(concepts: string[]): ObservationIntent[] { .map(c => c.slice(7) as ObservationIntent); } +// ===== Code Diff Types ===== + +/** + * Structured code diff from Edit/MultiEdit operations. + * Captures before/after snippets for understanding what changed. + */ +export interface CodeDiff { + /** File path that was edited */ + file: string; + /** Code before the change (truncated) */ + before: string; + /** Code after the change (truncated) */ + after: string; + /** Net line count change (positive=added, negative=removed) */ + changeLines: number; +} + +/** + * Extract structured code diffs from Edit/MultiEdit tool input. + * Returns compact before/after snippets (truncated to 200 chars each). + * For MultiEdit, captures up to 5 edits. + */ +export function extractCodeDiffs(toolName: string, toolInput: unknown): CodeDiff[] { + if (toolName !== 'Edit' && toolName !== 'MultiEdit') return []; + + try { + const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; + const diffs: CodeDiff[] = []; + const file = (input?.file_path || input?.path || '') as string; + + if (toolName === 'Edit') { + const oldStr = (input?.old_string || '') as string; + const newStr = (input?.new_string || '') as string; + if (oldStr || newStr) { + diffs.push({ + file, + before: oldStr.substring(0, 200), + after: newStr.substring(0, 200), + changeLines: newStr.split('\n').length - oldStr.split('\n').length, + }); + } + } else if (toolName === 'MultiEdit') { + const edits = (input?.edits || []) as Array<{ old_string?: string; new_string?: string }>; + for (const edit of edits.slice(0, 5)) { + const oldStr = (edit?.old_string || '') as string; + const newStr = (edit?.new_string || '') as string; + if (oldStr || newStr) { + diffs.push({ + file, + before: oldStr.substring(0, 200), + after: newStr.substring(0, 200), + changeLines: newStr.split('\n').length - oldStr.split('\n').length, + }); + } + } + } + + return diffs; + } catch { + return []; + } +} + +/** + * Format a code diff as a compact fact string. + * Example: `DIFF src/auth.ts: "function auth(user)" → "function auth(user, opts)"` + */ +export function formatDiffFact(diff: CodeDiff): string { + const fileName = diff.file.split(/[/\\]/).pop() || diff.file; + const beforeLine = diff.before.split('\n')[0].trim().substring(0, 60); + const afterLine = diff.after.split('\n')[0].trim().substring(0, 60); + return `DIFF ${fileName}: "${beforeLine}" → "${afterLine}"`; +} + /** * Session record for tracking */ @@ -421,6 +495,9 @@ export interface SessionSummary { /** Additional notes */ notes: string; + /** Decision rationale — why key changes were made */ + decisions: string[]; + /** Which prompt triggered this summary */ promptNumber: number; @@ -522,6 +599,7 @@ export interface ExportSummary { filesModified: string[]; nextSteps: string; notes: string; + decisions: string[]; } /** @@ -784,6 +862,15 @@ export function generateObservationNarrative( return `Wrote ${filePath || 'a file'} with new or updated content.`; case 'Edit': case 'MultiEdit': { + const diffs = extractCodeDiffs(toolName, toolInput); + if (diffs.length > 0) { + const diffDescs = diffs.map(d => { + const bLine = d.before.split('\n')[0].trim().substring(0, 50); + const aLine = d.after.split('\n')[0].trim().substring(0, 50); + return `"${bLine}" → "${aLine}"`; + }); + return `Edited ${filePath || 'a file'}: ${diffDescs.join('; ')}.`; + } const oldStr = input?.old_string ? `"${input.old_string.substring(0, 40)}..."` : 'code'; return `Edited ${filePath || 'a file'}, replacing ${oldStr} with updated content.`; } @@ -834,10 +921,18 @@ export function extractFacts(toolName: string, toolInput: unknown, toolResponse: if (filePath) facts.push(`File created/updated: ${filePath}`); break; case 'Edit': - case 'MultiEdit': + case 'MultiEdit': { if (filePath) facts.push(`File modified: ${filePath}`); - if (input?.old_string) facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || 'file'}`); + // Extract structured code diffs + const diffs = extractCodeDiffs(toolName, toolInput); + for (const diff of diffs) { + facts.push(formatDiffFact(diff)); + } + if (diffs.length === 0 && input?.old_string) { + facts.push(`Code replaced in ${filePath.split(/[/\\]/).pop() || 'file'}`); + } break; + } case 'Bash': { const cmd = input?.command || ''; facts.push(`Command executed: ${cmd.substring(0, 100)}`); From db9782130e0433513a5374bfaf9c8de1f3d8072b Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 10:35:28 +0900 Subject: [PATCH 10/21] feat: enhance AI enrichment and memory service with error handling and confidence scoring --- src/hooks/__tests__/ai-enrichment.test.ts | 71 +++++++- src/hooks/__tests__/service.test.ts | 209 ++++++++++++++++++++++ src/hooks/__tests__/types.test.ts | 167 +++++++++++++++++ src/hooks/ai-enrichment.ts | 16 +- src/hooks/service.ts | 93 +++++++++- src/hooks/types.ts | 61 ++++++- 6 files changed, 605 insertions(+), 12 deletions(-) diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index 1a2d725..2e4b4c3 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -316,16 +316,81 @@ describe('AI Enrichment Module', () => { expect(result!.facts.length).toBe(5); }); - it('should limit concepts to 5 items', () => { + it('should limit concepts to 8 items', () => { const json = JSON.stringify({ subtitle: 'Test', narrative: 'Test.', facts: [], - concepts: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7'], + concepts: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'c10'], }); const result = parseAIResponse(json); expect(result).not.toBeNull(); - expect(result!.concepts.length).toBe(5); + expect(result!.concepts.length).toBe(8); + }); + + it('should compute confidence score from AI-reported value', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['File has 200 lines'], + concepts: ['authentication'], + confidence: 0.92, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeCloseTo(0.92, 1); + }); + + it('should default confidence to 0.5 when not provided', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['Fact 1'], + concepts: ['auth'], + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeCloseTo(0.5, 1); + }); + + it('should penalize confidence for very short subtitle', () => { + const json = JSON.stringify({ + subtitle: 'Hi', + narrative: 'Read the auth module for login flow.', + facts: ['Fact 1'], + concepts: ['auth'], + confidence: 0.9, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThan(0.9); + }); + + it('should penalize confidence for empty facts', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: [], + concepts: ['auth'], + confidence: 0.9, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThan(0.9); + }); + + it('should clamp confidence to 0-1 range', () => { + const json = JSON.stringify({ + subtitle: 'Examining auth module', + narrative: 'Read the auth module for login flow.', + facts: ['Fact'], + concepts: ['auth'], + confidence: 1.5, + }); + const result = parseAIResponse(json); + expect(result).not.toBeNull(); + expect(result!.confidence).toBeLessThanOrEqual(1); + expect(result!.confidence).toBeGreaterThanOrEqual(0); }); it('should truncate individual fact strings to 200 chars', () => { diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 9ea6386..6fc7a42 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -1261,6 +1261,193 @@ describe('MemoryHookService', () => { }); }); + describe('errors in structured summaries', () => { + it('should extract errors from Bash stderr/stdout', async () => { + await service.initialize(); + await service.initSession('error-session', 'test-project'); + + await service.storeObservation( + 'error-session', 'test-project', 'Bash', + { command: 'npm run build' }, + { stderr: 'Error: TS2304 Cannot find name "foo"', stdout: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('error-session'); + expect(summary.errors).toBeDefined(); + expect(summary.errors.length).toBeGreaterThan(0); + expect(summary.errors[0]).toContain('TS2304'); + }); + + it('should not include false positive errors like "0 errors"', async () => { + await service.initialize(); + await service.initSession('no-error-session', 'test-project'); + + await service.storeObservation( + 'no-error-session', 'test-project', 'Bash', + { command: 'tsc' }, + { stdout: 'Build completed with 0 errors', stderr: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('no-error-session'); + expect(summary.errors.length).toBe(0); + }); + + it('should return empty errors when no Bash observations', async () => { + await service.initialize(); + await service.initSession('read-only-session', 'test-project'); + + await service.storeObservation( + 'read-only-session', 'test-project', 'Read', + { file_path: 'file.ts' }, {}, TEST_DIR + ); + + const summary = await service.generateStructuredSummary('read-only-session'); + expect(summary.errors).toEqual([]); + }); + + it('should cap errors at 10', async () => { + await service.initialize(); + await service.initSession('many-error-session', 'test-project'); + + // Create output with many error lines + const errorLines = Array.from({ length: 20 }, (_, i) => `Error: issue ${i}`).join('\n'); + await service.storeObservation( + 'many-error-session', 'test-project', 'Bash', + { command: 'build' }, + { stdout: errorLines, stderr: '' }, + TEST_DIR + ); + + const summary = await service.generateStructuredSummary('many-error-session'); + expect(summary.errors.length).toBeLessThanOrEqual(10); + }); + + it('should include errors in saved session summary', async () => { + await service.initialize(); + await service.initSession('saved-error', 'test-project'); + + await service.storeObservation( + 'saved-error', 'test-project', 'Bash', + { command: 'tsc' }, + { stderr: 'fatal error: out of memory' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('saved-error'); + const saved = await service.saveSessionSummary(structured); + expect(saved.errors.length).toBeGreaterThan(0); + + // Verify roundtrip through DB + const summaries = await service.getRecentSummaries('test-project'); + const found = summaries.find(s => s.sessionId === 'saved-error'); + expect(found).toBeDefined(); + expect(found!.errors.length).toBeGreaterThan(0); + }); + + it('should show errors in context markdown', async () => { + await service.initialize(); + await service.initSession('ctx-error', 'test-project'); + + await service.storeObservation( + 'ctx-error', 'test-project', 'Bash', + { command: 'npm test' }, + { stderr: 'Error: Test failed unexpectedly' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('ctx-error'); + await service.saveSessionSummary(structured); + await service.completeSession('ctx-error', 'Done'); + + const ctx = await service.getContext('test-project'); + expect(ctx.markdown).toContain('Errors'); + }); + }); + + describe('cross-session pattern detection', () => { + it('should detect recurring concepts across observations', async () => { + await service.initialize(); + + // Create multiple sessions with overlapping concepts + await service.initSession('pattern-s1', 'test-project'); + await service.storeObservation( + 'pattern-s1', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function login(user) {', new_string: 'function login(user, opts) {' }, + {}, TEST_DIR + ); + + await service.initSession('pattern-s2', 'test-project'); + await service.storeObservation( + 'pattern-s2', 'test-project', 'Edit', + { file_path: 'src/auth.ts', old_string: 'function logout() {', new_string: 'function logout(session) {' }, + {}, TEST_DIR + ); + + const patterns = await service.detectCrossSessionPatterns('test-project'); + + // Both edits touched 'authentication' concept from auth.ts path + expect(patterns.length).toBeGreaterThan(0); + // At least some patterns should appear with count >= 2 + expect(patterns.every(p => p.count >= 2)).toBe(true); + expect(patterns.every(p => p.category)).toBe(true); + }); + + it('should categorize patterns by prefix', async () => { + await service.initialize(); + + // Create observations with fn: and intent: concepts + await service.initSession('cat-s1', 'test-project'); + await service.saveUserPrompt('cat-s1', 'test-project', 'Fix the login bug'); + await service.storeObservation( + 'cat-s1', 'test-project', 'Edit', + { file_path: 'src/app.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(opts) {' }, + {}, TEST_DIR + ); + + await service.initSession('cat-s2', 'test-project'); + await service.saveUserPrompt('cat-s2', 'test-project', 'Fix the signup bug'); + await service.storeObservation( + 'cat-s2', 'test-project', 'Edit', + { file_path: 'src/signup.ts', old_string: 'function handleAuth() {', new_string: 'function handleAuth(token) {' }, + {}, TEST_DIR + ); + + const patterns = await service.detectCrossSessionPatterns('test-project'); + + // Check category values + const categories = new Set(patterns.map(p => p.category)); + // Should have valid categories + for (const cat of categories) { + expect(['topic', 'intent', 'function', 'class', 'code-pattern']).toContain(cat); + } + }); + + it('should return empty for project with no observations', async () => { + await service.initialize(); + const patterns = await service.detectCrossSessionPatterns('empty-project'); + expect(patterns).toEqual([]); + }); + + it('should respect limit parameter', async () => { + await service.initialize(); + await service.initSession('limit-s1', 'test-project'); + + // Create many observations to generate many concepts + for (let i = 0; i < 5; i++) { + await service.storeObservation( + 'limit-s1', 'test-project', 'Edit', + { file_path: `src/file${i}.ts`, old_string: 'const a = 1;', new_string: 'const a = 2;' }, + {}, TEST_DIR + ); + } + + const patterns = await service.detectCrossSessionPatterns('test-project', 3); + expect(patterns.length).toBeLessThanOrEqual(3); + }); + }); + describe('export/import', () => { it('should export sessions with observations and prompts', async () => { await service.initialize(); @@ -1335,5 +1522,27 @@ describe('MemoryHookService', () => { // Both skip observations since content_hash already in DB expect(result1.skipped.observations + result2.skipped.observations).toBeGreaterThanOrEqual(1); }); + + it('should preserve errors in export/import roundtrip', async () => { + await service.initialize(); + await service.initSession('err-export', 'test-project'); + + await service.storeObservation( + 'err-export', 'test-project', 'Bash', + { command: 'build' }, + { stderr: 'Error: compilation failed' }, + TEST_DIR + ); + + const structured = await service.generateStructuredSummary('err-export'); + await service.saveSessionSummary(structured); + await service.completeSession('err-export', 'Done'); + + const exported = await service.exportToJSON('test-project', ['err-export']); + const session = exported.sessions.find(s => s.sessionId === 'err-export'); + expect(session).toBeDefined(); + expect(session!.summary).toBeDefined(); + expect(session!.summary!.errors.length).toBeGreaterThan(0); + }); }); }); diff --git a/src/hooks/__tests__/types.test.ts b/src/hooks/__tests__/types.test.ts index af06850..df0a3f6 100644 --- a/src/hooks/__tests__/types.test.ts +++ b/src/hooks/__tests__/types.test.ts @@ -17,6 +17,7 @@ import { extractConcepts, extractCodeDiffs, formatDiffFact, + classifyChangeType, detectIntent, extractIntents, truncate, @@ -669,6 +670,63 @@ describe('Hook Types Utilities', () => { const concepts = extractConcepts('Read', null); expect(concepts).toEqual([]); }); + + it('should extract fn: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/auth.ts', + old_string: 'function login(user) {', + new_string: 'function login(user, opts) {', + }); + expect(concepts.some(c => c.startsWith('fn:'))).toBe(true); + expect(concepts).toContain('fn:login'); + }); + + it('should extract class: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/service.ts', + old_string: 'class AuthService {', + new_string: 'class AuthService extends BaseService {', + }); + expect(concepts).toContain('class:AuthService'); + }); + + it('should extract pattern: concepts from Edit tool', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/types.ts', + old_string: 'export interface Foo {', + new_string: 'export interface Foo { bar: string; }', + }); + expect(concepts).toContain('pattern:export'); + expect(concepts).toContain('pattern:interface'); + }); + + it('should extract async pattern concept', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/api.ts', + old_string: 'async function fetchData() {', + new_string: 'async function fetchData(id: string) {', + }); + expect(concepts).toContain('pattern:async'); + expect(concepts).toContain('fn:fetchData'); + }); + + it('should extract error-handling pattern concept', () => { + const concepts = extractConcepts('Edit', { + file_path: 'src/handler.ts', + old_string: 'return result;', + new_string: 'try { return result; } catch (e) { throw e; }', + }); + expect(concepts).toContain('pattern:error-handling'); + }); + + it('should not extract fn/class/pattern from non-Edit tools', () => { + const concepts = extractConcepts('Read', { + file_path: 'src/auth.ts', + }); + expect(concepts.some(c => c.startsWith('fn:'))).toBe(false); + expect(concepts.some(c => c.startsWith('class:'))).toBe(false); + expect(concepts.some(c => c.startsWith('pattern:'))).toBe(false); + }); }); describe('detectIntent', () => { @@ -836,6 +894,67 @@ describe('Hook Types Utilities', () => { const diffs = extractCodeDiffs('Edit', { file_path: 'a.ts' }); expect(diffs).toEqual([]); }); + + it('should include changeType in extracted diffs', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'function foo() {', + new_string: 'function foo(arg) {', + }); + expect(diffs[0].changeType).toBe('modification'); + }); + + it('should classify addition when old_string is empty', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: '', + new_string: 'const newVar = 1;', + }); + expect(diffs[0].changeType).toBe('addition'); + }); + + it('should classify replacement for different first tokens', () => { + const diffs = extractCodeDiffs('Edit', { + file_path: 'a.ts', + old_string: 'import { a } from "./a"', + new_string: 'export { b } from "./b"', + }); + expect(diffs[0].changeType).toBe('replacement'); + }); + + it('should include changeType in MultiEdit diffs', () => { + const diffs = extractCodeDiffs('MultiEdit', { + file_path: 'a.ts', + edits: [ + { old_string: '', new_string: 'new line' }, + { old_string: 'const a = 1;', new_string: 'const a = 2;' }, + ], + }); + expect(diffs[0].changeType).toBe('addition'); + expect(diffs[1].changeType).toBe('modification'); + }); + }); + + describe('classifyChangeType', () => { + it('should classify addition (empty before, non-empty after)', () => { + expect(classifyChangeType('', 'const x = 1;')).toBe('addition'); + expect(classifyChangeType(' ', 'new code')).toBe('addition'); + }); + + it('should classify deletion (non-empty before, empty after)', () => { + expect(classifyChangeType('const x = 1;', '')).toBe('deletion'); + expect(classifyChangeType('old code', ' ')).toBe('deletion'); + }); + + it('should classify modification (same first token)', () => { + expect(classifyChangeType('function login(user) {', 'function login(user, opts) {')).toBe('modification'); + expect(classifyChangeType('const x = 1;', 'const x = 2;')).toBe('modification'); + }); + + it('should classify replacement (different first token)', () => { + expect(classifyChangeType('const x = 1;', 'let y = 2;')).toBe('replacement'); + expect(classifyChangeType('import { a }', 'export { b }')).toBe('replacement'); + }); }); describe('formatDiffFact', () => { @@ -845,6 +964,7 @@ describe('Hook Types Utilities', () => { before: 'function login(user) {', after: 'function login(user, opts) {', changeLines: 0, + changeType: 'modification', }); expect(fact).toContain('DIFF'); expect(fact).toContain('auth.ts'); @@ -858,6 +978,7 @@ describe('Hook Types Utilities', () => { before: 'old', after: 'new', changeLines: 0, + changeType: 'modification', }); expect(fact).toContain('file.ts'); expect(fact).not.toContain('/very/long'); @@ -870,9 +991,55 @@ describe('Hook Types Utilities', () => { before: longLine, after: 'short', changeLines: 0, + changeType: 'modification', }); // First line is truncated to 60 chars expect(fact.length).toBeLessThan(200); }); + + it('should show [addition] tag for addition changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: '', + after: 'new code', + changeLines: 1, + changeType: 'addition', + }); + expect(fact).toContain('[addition]'); + }); + + it('should show [deletion] tag for deletion changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'old code', + after: '', + changeLines: -1, + changeType: 'deletion', + }); + expect(fact).toContain('[deletion]'); + }); + + it('should show [replacement] tag for replacement changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'const x = 1;', + after: 'let y = 2;', + changeLines: 0, + changeType: 'replacement', + }); + expect(fact).toContain('[replacement]'); + }); + + it('should not show tag for modification changeType', () => { + const fact = formatDiffFact({ + file: 'a.ts', + before: 'const x = 1;', + after: 'const x = 2;', + changeLines: 0, + changeType: 'modification', + }); + expect(fact).not.toContain('[modification]'); + expect(fact).not.toContain('['); + }); }); }); diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index b9928e1..83b53df 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -20,6 +20,8 @@ export interface EnrichedObservation { narrative: string; facts: string[]; concepts: string[]; + /** Confidence score 0.0-1.0 indicating extraction quality */ + confidence: number; } /** @@ -134,7 +136,8 @@ Return ONLY a JSON object (no markdown, no code fences) with these fields: "subtitle": "Brief context description (5-10 words, e.g. 'Examining authentication module')", "narrative": "One sentence explaining what happened and why (e.g. 'Read the authentication module to understand the login flow before making changes.')", "facts": ["Array of factual observations", "e.g. 'File auth.ts contains 150 lines'", "Max 5 facts"], - "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Include 'intent:' tags for: bugfix, feature, refactor, testing, investigation, documentation, configuration, optimization", "Max 5 concepts"] + "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Include 'intent:' tags for: bugfix, feature, refactor, testing, investigation, documentation, configuration, optimization", "Include 'fn:' for functions changed, 'class:' for classes, 'pattern:' for patterns used", "Max 8 concepts"], + "confidence": 0.85 }`; } @@ -167,11 +170,20 @@ export function parseAIResponse(text: string): EnrichedObservation | null { return null; } + // Compute confidence: AI-reported value weighted with heuristic checks + let confidence = typeof parsed.confidence === 'number' ? Math.max(0, Math.min(1, parsed.confidence)) : 0.5; + // Penalize empty or very short fields + if (parsed.subtitle.length < 5) confidence *= 0.7; + if (parsed.narrative.length < 10) confidence *= 0.7; + if (parsed.facts.length === 0) confidence *= 0.8; + confidence = Math.round(confidence * 100) / 100; + return { subtitle: parsed.subtitle.substring(0, 200), narrative: parsed.narrative.substring(0, 500), facts: parsed.facts.slice(0, 5).map((f: unknown) => String(f).substring(0, 200)), - concepts: parsed.concepts.slice(0, 5).map((c: unknown) => String(c).substring(0, 50)), + concepts: parsed.concepts.slice(0, 8).map((c: unknown) => String(c).substring(0, 50)), + confidence, }; } catch { return null; diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 1ef447a..5b26837 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -997,6 +997,9 @@ export class MemoryHookService { if (summary.decisions && summary.decisions.length > 0) { lines.push(`**Decisions:** ${summary.decisions.slice(0, 5).join('; ')}`); } + if (summary.errors && summary.errors.length > 0) { + lines.push(`**Errors:** ${summary.errors.slice(0, 3).join('; ')}`); + } if (summary.nextSteps) { lines.push(`**Next Steps:** ${summary.nextSteps}`); } @@ -1142,11 +1145,12 @@ export class MemoryHookService { const prompts = await this.getSessionPrompts(sessionId); const session = this.getSession(sessionId); - // Extract file paths, commands, and decisions from observations + // Extract file paths, commands, decisions, and errors from observations const filesRead: Set = new Set(); const filesModified: Set = new Set(); const commands: string[] = []; const decisions: string[] = []; + const errors: string[] = []; for (const obs of observations) { try { @@ -1175,6 +1179,28 @@ export class MemoryHookService { } } else if (obs.type === 'execute' && input.command) { commands.push(input.command.substring(0, 80)); + + // Extract errors from Bash output + try { + const response = JSON.parse(obs.toolResponse); + const stderr = (response?.stderr || '') as string; + const stdout = (response?.stdout || response?.output || '') as string; + const output = stderr + '\n' + stdout; + + // Detect error patterns + const errorLines = output.split('\n').filter((line: string) => { + const l = line.toLowerCase(); + return (l.includes('error') || l.includes('failed') || l.includes('exception') || l.includes('fatal')) + && !l.includes('0 errors') && !l.includes('no errors') && line.trim().length > 5; + }); + + for (const errLine of errorLines.slice(0, 3)) { + const trimmed = errLine.trim().substring(0, 150); + if (trimmed && !errors.includes(trimmed)) { + errors.push(trimmed); + } + } + } catch { /* ignore response parse errors */ } } } catch { // Ignore parse errors @@ -1212,6 +1238,7 @@ export class MemoryHookService { nextSteps: '', notes, decisions: decisions.slice(0, 10), + errors: errors.slice(0, 10), promptNumber: prompts.length, }; } @@ -1227,8 +1254,8 @@ export class MemoryHookService { const now = Date.now(); const result = this.db!.prepare(` INSERT INTO session_summaries - (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, prompt_number, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, errors, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( summary.sessionId, summary.project, @@ -1239,6 +1266,7 @@ export class MemoryHookService { summary.nextSteps, summary.notes, JSON.stringify(summary.decisions || []), + JSON.stringify(summary.errors || []), summary.promptNumber, now ); @@ -1334,6 +1362,7 @@ export class MemoryHookService { nextSteps: row.next_steps as string || '', notes: row.notes as string || '', decisions: JSON.parse((row.decisions as string) || '[]'), + errors: JSON.parse((row.errors as string) || '[]'), promptNumber: row.prompt_number as number || 0, createdAt: row.created_at as number, }; @@ -1456,6 +1485,52 @@ export class MemoryHookService { }; } + // ===== Cross-Session Pattern Detection ===== + + /** + * Detect recurring patterns across sessions for a project. + * Analyzes concept frequency across recent observations to identify + * common workflows, frequently modified files, and recurring intents. + * Returns top patterns sorted by frequency. + */ + async detectCrossSessionPatterns(project: string, limit: number = 10): Promise> { + await this.ensureInitialized(); + + // Get concepts from recent observations (last 30 days) + const cutoff = Date.now() - 30 * 86400000; + const rows = this.db!.prepare(` + SELECT concepts FROM observations + WHERE project = ? AND timestamp > ? AND concepts IS NOT NULL + `).all(project, cutoff) as { concepts: string }[]; + + // Count concept frequency + const conceptCounts = new Map(); + for (const row of rows) { + try { + const concepts = JSON.parse(row.concepts) as string[]; + for (const concept of concepts) { + conceptCounts.set(concept, (conceptCounts.get(concept) || 0) + 1); + } + } catch { /* ignore */ } + } + + // Categorize and sort + const patterns = Array.from(conceptCounts.entries()) + .filter(([, count]) => count >= 2) // Only patterns that appear at least twice + .map(([pattern, count]) => { + let category = 'topic'; + if (pattern.startsWith('intent:')) category = 'intent'; + else if (pattern.startsWith('fn:')) category = 'function'; + else if (pattern.startsWith('class:')) category = 'class'; + else if (pattern.startsWith('pattern:')) category = 'code-pattern'; + return { pattern, count, category }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + return patterns; + } + // ===== Export/Import ===== /** @@ -1529,6 +1604,7 @@ export class MemoryHookService { nextSteps: summary.next_steps as string, notes: summary.notes as string, decisions: JSON.parse((summary.decisions as string) || '[]'), + errors: JSON.parse((summary.errors as string) || '[]'), } : undefined, }); } @@ -1628,12 +1704,13 @@ export class MemoryHookService { if (session.summary) { const s = session.summary; this.db!.prepare(` - INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, prompt_number, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_summaries (session_id, project, request, completed, files_read, files_modified, next_steps, notes, decisions, errors, prompt_number, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( newSessionId, session.project, s.request, s.completed, JSON.stringify(s.filesRead || []), JSON.stringify(s.filesModified || []), s.nextSteps || '', s.notes || '', JSON.stringify(s.decisions || []), + JSON.stringify(s.errors || []), session.prompts.length, Date.now() ); } @@ -1729,6 +1806,7 @@ export class MemoryHookService { next_steps TEXT, notes TEXT, decisions TEXT DEFAULT '[]', + errors TEXT DEFAULT '[]', prompt_number INTEGER, created_at INTEGER NOT NULL, embedding BLOB, @@ -1826,12 +1904,15 @@ export class MemoryHookService { } } catch { /* ignore */ } - // Migrate session_summaries: add decisions column + // Migrate session_summaries: add decisions + errors columns try { const summaryCols = this.db.prepare("PRAGMA table_info(session_summaries)").all() as Array<{ name: string }>; if (!summaryCols.some(c => c.name === 'decisions')) { this.db.exec("ALTER TABLE session_summaries ADD COLUMN decisions TEXT DEFAULT '[]'"); } + if (!summaryCols.some(c => c.name === 'errors')) { + this.db.exec("ALTER TABLE session_summaries ADD COLUMN errors TEXT DEFAULT '[]'"); + } } catch { /* ignore */ } // Add embedding column to all session tables diff --git a/src/hooks/types.ts b/src/hooks/types.ts index c3de686..276e470 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -338,6 +338,11 @@ export function extractIntents(concepts: string[]): ObservationIntent[] { * Structured code diff from Edit/MultiEdit operations. * Captures before/after snippets for understanding what changed. */ +/** + * Change type classification for code diffs + */ +export type DiffChangeType = 'addition' | 'deletion' | 'modification' | 'replacement'; + export interface CodeDiff { /** File path that was edited */ file: string; @@ -347,6 +352,21 @@ export interface CodeDiff { after: string; /** Net line count change (positive=added, negative=removed) */ changeLines: number; + /** Classified change type */ + changeType: DiffChangeType; +} + +/** + * Classify the type of change in a diff + */ +export function classifyChangeType(before: string, after: string): DiffChangeType { + if (!before.trim() && after.trim()) return 'addition'; + if (before.trim() && !after.trim()) return 'deletion'; + // If structure is similar (same first token), it's a modification; otherwise replacement + const beforeFirst = before.trim().split(/[\s({\[]/)[0]; + const afterFirst = after.trim().split(/[\s({\[]/)[0]; + if (beforeFirst === afterFirst) return 'modification'; + return 'replacement'; } /** @@ -371,6 +391,7 @@ export function extractCodeDiffs(toolName: string, toolInput: unknown): CodeDiff before: oldStr.substring(0, 200), after: newStr.substring(0, 200), changeLines: newStr.split('\n').length - oldStr.split('\n').length, + changeType: classifyChangeType(oldStr, newStr), }); } } else if (toolName === 'MultiEdit') { @@ -384,6 +405,7 @@ export function extractCodeDiffs(toolName: string, toolInput: unknown): CodeDiff before: oldStr.substring(0, 200), after: newStr.substring(0, 200), changeLines: newStr.split('\n').length - oldStr.split('\n').length, + changeType: classifyChangeType(oldStr, newStr), }); } } @@ -403,7 +425,8 @@ export function formatDiffFact(diff: CodeDiff): string { const fileName = diff.file.split(/[/\\]/).pop() || diff.file; const beforeLine = diff.before.split('\n')[0].trim().substring(0, 60); const afterLine = diff.after.split('\n')[0].trim().substring(0, 60); - return `DIFF ${fileName}: "${beforeLine}" → "${afterLine}"`; + const tag = diff.changeType !== 'modification' ? ` [${diff.changeType}]` : ''; + return `DIFF ${fileName}${tag}: "${beforeLine}" → "${afterLine}"`; } /** @@ -498,6 +521,9 @@ export interface SessionSummary { /** Decision rationale — why key changes were made */ decisions: string[]; + /** Errors encountered during session */ + errors: string[]; + /** Which prompt triggered this summary */ promptNumber: number; @@ -600,6 +626,7 @@ export interface ExportSummary { nextSteps: string; notes: string; decisions: string[]; + errors: string[]; } /** @@ -1009,6 +1036,38 @@ export function extractConcepts(toolName: string, toolInput: unknown, _toolRespo } } + // Extract function/class names from Edit/MultiEdit for code-specific searchability + if (toolName === 'Edit' || toolName === 'MultiEdit') { + const oldStr = (input?.old_string || '') as string; + const newStr = (input?.new_string || '') as string; + const combined = oldStr + '\n' + newStr; + + // Extract function names + const funcMatches = combined.match(/(?:function|async function|const|let|var)\s+(\w{3,})/g); + if (funcMatches) { + for (const m of funcMatches.slice(0, 3)) { + const name = m.replace(/(?:function|async function|const|let|var)\s+/, ''); + concepts.add(`fn:${name}`); + } + } + + // Extract class names + const classMatches = combined.match(/class\s+(\w{3,})/g); + if (classMatches) { + for (const m of classMatches.slice(0, 2)) { + concepts.add(`class:${m.replace('class ', '')}`); + } + } + + // Extract patterns: import, export, interface, type, enum + if (/\bimport\b/.test(combined)) concepts.add('pattern:import'); + if (/\bexport\b/.test(combined)) concepts.add('pattern:export'); + if (/\binterface\b/.test(combined)) concepts.add('pattern:interface'); + if (/\benum\b/.test(combined)) concepts.add('pattern:enum'); + if (/\btry\s*\{/.test(combined)) concepts.add('pattern:error-handling'); + if (/\basync\b/.test(combined)) concepts.add('pattern:async'); + } + // Tool-based concepts switch (toolName) { case 'Bash': { From 8eff0b74f9e2ac772608d35cd45a88a4f38d6064 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 11:03:31 +0900 Subject: [PATCH 11/21] feat: implement task queue retry limits and enhance error handling in MemoryHookService --- src/hooks/__tests__/service.test.ts | 84 +++++++++++++++++++++++++++++ src/hooks/cli.ts | 12 +++++ src/hooks/service.ts | 51 +++++++++++++----- 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 6fc7a42..51986a5 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -915,6 +915,90 @@ describe('MemoryHookService', () => { }); }); + describe('task queue retry limits', () => { + const originalEnv = process.env.AGENTKITS_AI_ENRICHMENT; + + beforeEach(() => { + delete process.env.AGENTKITS_AI_ENRICHMENT; + resetAIEnrichmentCache(); + }); + + afterEach(() => { + _setRunClaudePrintMockForTesting(null); + resetAIEnrichmentCache(); + if (originalEnv === undefined) { + delete process.env.AGENTKITS_AI_ENRICHMENT; + } else { + process.env.AGENTKITS_AI_ENRICHMENT = originalEnv; + } + }); + + it('should skip tasks that have reached max retries', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + + // Queue a compress task and set retry_count to 3 (max) + service.queueTask('compress', 'observations', obs.id); + service.db.prepare("UPDATE task_queue SET retry_count = 3 WHERE task_type = 'compress'").run(); + + _setRunClaudePrintMockForTesting(() => + JSON.stringify({ compressed_summary: 'Should not run' }) + ); + + const count = await service.processCompressionQueue(); + expect(count).toBe(0); // Skipped — retry limit reached + + // Task should still be in queue as pending with retry_count=3 + const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; + expect(task.status).toBe('pending'); + expect(task.retry_count).toBe(3); + }); + + it('should increment retry_count on failure and mark failed at max', async () => { + await service.initSession('session-1', 'test-project'); + + // Queue a compress task for observations table + service.db.prepare( + "INSERT INTO task_queue (task_type, target_table, target_id, created_at) VALUES ('compress', 'observations', 'bad-id', ?)" + ).run(Date.now()); + + // Monkey-patch compressObservation to throw (simulating unhandled error) + const origCompress = service.compressObservation.bind(service); + service.compressObservation = async () => { throw new Error('unexpected failure'); }; + + // Worker loop retries within a single call — all 3 attempts exhaust in one run + const count = await service.processCompressionQueue(); + expect(count).toBe(3); // 3 failed attempts processed + + // Task should now be marked 'failed' with retry_count=3 + const task = service.db.prepare("SELECT * FROM task_queue WHERE task_type = 'compress'").get() as Record; + expect(task.status).toBe('failed'); + expect(task.retry_count).toBe(3); + + // Next call skips entirely — no pending tasks under retry limit + const count2 = await service.processCompressionQueue(); + expect(count2).toBe(0); + + // Restore + service.compressObservation = origCompress; + }); + + it('should include retry_count column in task_queue schema', async () => { + await service.initialize(); + const cols = service.db.prepare("PRAGMA table_info(task_queue)").all() as Array<{ name: string }>; + expect(cols.some(c => c.name === 'retry_count')).toBe(true); + }); + + it('should default retry_count to 0 for new tasks', async () => { + await service.initSession('session-1', 'test-project'); + service.queueTask('embed', 'observations', 'test-id'); + const task = service.db.prepare("SELECT retry_count FROM task_queue WHERE task_type = 'embed'").get() as Record; + expect(task.retry_count).toBe(0); + }); + }); + describe('computeContentHash', () => { it('should produce consistent hashes', () => { const hash1 = computeContentHash('a', 'b', 'c'); diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index b065c09..071331d 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -114,9 +114,13 @@ async function main(): Promise { const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); try { await svc.processEmbeddingQueue(); } finally { + clearTimeout(killTimer); await svc.shutdown(); } process.exit(0); @@ -133,9 +137,13 @@ async function main(): Promise { const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); try { await svc.processEnrichmentQueue(); } finally { + clearTimeout(killTimer); await svc.shutdown(); } process.exit(0); @@ -151,9 +159,13 @@ async function main(): Promise { const cleanup = async () => { try { await svc.shutdown(); } catch {} process.exit(0); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + // Safety: self-terminate after 5 minutes to prevent zombie processes + const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); + killTimer.unref(); try { await svc.processCompressionQueue(); } finally { + clearTimeout(killTimer); await svc.shutdown(); } process.exit(0); diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 5b26837..cf60fc4 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -599,6 +599,8 @@ export class MemoryHookService { /** Max records to process per worker invocation */ private static readonly WORKER_BATCH_LIMIT = 200; + /** Max retries before marking a task as permanently failed */ + private static readonly MAX_TASK_RETRIES = 3; /** * Queue a background task. Inserts into SQLite task_queue — atomic, <1ms. @@ -704,8 +706,8 @@ export class MemoryHookService { // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions const claimEmbedTask = this.db!.transaction(() => { const item = this.db!.prepare( - "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'embed' AND status = 'pending' ORDER BY id ASC LIMIT 1" - ).get() as { id: number; target_table: string; target_id: string } | undefined; + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'embed' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; if (item) { this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); } @@ -748,8 +750,14 @@ export class MemoryHookService { this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); count++; } catch { - this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); - count++; // Still count failed attempts to prevent infinite loop on permanently failing tasks + // Increment retry_count; mark as 'failed' if max retries exceeded + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } + count++; } } @@ -802,8 +810,8 @@ export class MemoryHookService { // Atomic claim: SELECT + UPDATE in a single transaction to prevent race conditions const claimEnrichTask = this.db!.transaction(() => { const item = this.db!.prepare( - "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' ORDER BY id ASC LIMIT 1" - ).get() as { id: number; target_table: string; target_id: string } | undefined; + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; if (item) { this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); } @@ -825,8 +833,13 @@ export class MemoryHookService { this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); count++; } catch { - this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); - count++; // Still count failed attempts to prevent infinite loop on permanently failing tasks + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } + count++; } } } finally { @@ -852,8 +865,8 @@ export class MemoryHookService { // Atomic claim: SELECT + UPDATE in a single transaction const claimCompressTask = this.db!.transaction(() => { const item = this.db!.prepare( - "SELECT id, target_table, target_id FROM task_queue WHERE task_type = 'compress' AND status = 'pending' ORDER BY id ASC LIMIT 1" - ).get() as { id: number; target_table: string; target_id: string } | undefined; + "SELECT id, target_table, target_id, retry_count FROM task_queue WHERE task_type = 'compress' AND status = 'pending' AND retry_count < ? ORDER BY id ASC LIMIT 1" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { id: number; target_table: string; target_id: string; retry_count: number } | undefined; if (item) { this.db!.prepare("UPDATE task_queue SET status = 'processing' WHERE id = ?").run(item.id); } @@ -875,7 +888,12 @@ export class MemoryHookService { this.db!.prepare('DELETE FROM task_queue WHERE id = ?').run(item.id); count++; } catch { - this.db!.prepare("UPDATE task_queue SET status = 'pending' WHERE id = ?").run(item.id); + const newRetry = (item.retry_count || 0) + 1; + if (newRetry >= MemoryHookService.MAX_TASK_RETRIES) { + this.db!.prepare("UPDATE task_queue SET status = 'failed', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } else { + this.db!.prepare("UPDATE task_queue SET status = 'pending', retry_count = ? WHERE id = ?").run(newRetry, item.id); + } count++; } } @@ -1823,7 +1841,8 @@ export class MemoryHookService { target_table TEXT NOT NULL, target_id TEXT NOT NULL, created_at INTEGER NOT NULL, - status TEXT DEFAULT 'pending' + status TEXT DEFAULT 'pending', + retry_count INTEGER DEFAULT 0 ) `); @@ -1915,6 +1934,14 @@ export class MemoryHookService { } } catch { /* ignore */ } + // Migrate task_queue: add retry_count column + try { + const queueCols = this.db.prepare("PRAGMA table_info(task_queue)").all() as Array<{ name: string }>; + if (!queueCols.some(c => c.name === 'retry_count')) { + this.db.exec('ALTER TABLE task_queue ADD COLUMN retry_count INTEGER DEFAULT 0'); + } + } catch { /* ignore */ } + // Add embedding column to all session tables for (const table of ['observations', 'user_prompts', 'session_summaries']) { const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; From 2ddbe38418b6a35dcdda35ed5927f9f73d7915db Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 11:45:29 +0900 Subject: [PATCH 12/21] feat: add persistent memory settings management and skill installation functionality --- package.json | 1 + skills/recall/SKILL.md | 67 +++++++++++ src/cli/setup.ts | 94 +++++++++++++++ src/hooks/__tests__/service.test.ts | 170 +++++++++++++++++++++++++++- src/hooks/cli.ts | 46 +++++++- src/hooks/service.ts | 47 +++++++- src/hooks/types.ts | 13 +++ 7 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 skills/recall/SKILL.md diff --git a/package.json b/package.json index e02a2dc..dfe8ca8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dist", "src", "assets", + "skills", "hooks.json", "LICENSE", "README.md" diff --git a/skills/recall/SKILL.md b/skills/recall/SKILL.md new file mode 100644 index 0000000..0239d99 --- /dev/null +++ b/skills/recall/SKILL.md @@ -0,0 +1,67 @@ +--- +name: recall +description: Use when you need to recall past work, previous decisions, error solutions, or project history. Activates the 3-layer memory search workflow for token-efficient retrieval. +--- + +# Memory Recall Skill + +## When to Activate + +Use this skill when: +- User asks about past work, previous sessions, or what was done before +- User references a decision, pattern, or error you don't have context for +- You need project history, conventions, or architectural decisions +- User asks "what did we do about X?" or "how did we handle Y?" +- You're missing context that should exist from earlier sessions +- Starting work on a feature that may have prior decisions recorded + +## Prerequisites + +Before searching, check if memories exist: +``` +memory_status() +``` +If the database is empty, skip recall and inform the user. + +## 3-Layer Search Workflow + +### Layer 1: Search Index (lightweight, ~50 tokens/result) +``` +memory_search(query="your search term") +``` +- Returns IDs, titles, categories, dates, and relevance scores +- Filter by category: `decision`, `pattern`, `error`, `context`, `observation` +- Filter by date: `dateStart="2025-01-01"`, `dateEnd="2025-12-31"` +- Sort: `orderBy="relevance"` (default), `"date_asc"`, `"date_desc"` + +### Layer 2: Timeline Context (understand what happened around a result) +``` +memory_timeline(anchor="MEMORY_ID") +``` +- Shows what happened before/after a specific memory +- Helps understand the sequence of events +- Use when you need temporal context + +### Layer 3: Full Details (only for filtered IDs) +``` +memory_details(ids=["ID1", "ID2"]) +``` +- Returns complete content for selected memories +- Limit to 3-5 IDs at a time to conserve tokens +- NEVER fetch details without filtering through Layer 1 first + +## Quick Topic Recall + +For a fast overview of everything known about a topic: +``` +memory_recall(topic="authentication") +``` +This returns a grouped summary. Follow up with `memory_details` for specifics. + +## Token Efficiency Rules + +1. ALWAYS start with `memory_search` (Layer 1), never jump to `memory_details` +2. Review search results and select only relevant IDs before fetching details +3. Use filters (category, date range) to narrow results +4. Limit `memory_details` to 3-5 IDs per call +5. This workflow saves ~87% tokens vs fetching everything at once diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4a474dd..7fc23c2 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -303,6 +303,85 @@ function configureMcp( return { configured, skipped }; } +/** + * Install memory skills to .claude/skills/ + * Copies SKILL.md files from package to project's .claude/skills/ directory + */ +function installSkills( + projectDir: string, + force: boolean, + asJson: boolean +): { installed: string[]; skipped: string[] } { + const installed: string[] = []; + const skipped: string[] = []; + + // Resolve package root: setup.ts is at dist/cli/setup.js → package root is ../../ + const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..'); + const sourceSkillsDir = path.join(packageRoot, 'skills'); + + if (!fs.existsSync(sourceSkillsDir)) { + return { installed, skipped }; + } + + let skillDirs: fs.Dirent[]; + try { + skillDirs = fs.readdirSync(sourceSkillsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()); + } catch { + return { installed, skipped }; + } + + for (const skillDir of skillDirs) { + const sourcePath = path.join(sourceSkillsDir, skillDir.name, 'SKILL.md'); + const targetDir = path.join(projectDir, '.claude', 'skills', skillDir.name); + const targetPath = path.join(targetDir, 'SKILL.md'); + + if (!fs.existsSync(sourcePath)) continue; + + if (fs.existsSync(targetPath) && !force) { + skipped.push(skillDir.name); + continue; + } + + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + fs.copyFileSync(sourcePath, targetPath); + installed.push(skillDir.name); + } + + if (!asJson && installed.length > 0) { + console.log('\n🎯 Skills installed:'); + for (const skill of installed) { + console.log(` ✓ ${skill} (.claude/skills/${skill}/SKILL.md)`); + } + } + + return { installed, skipped }; +} + +/** + * Create default memory settings file if not exists + */ +function createDefaultSettings(memoryDir: string, force: boolean): boolean { + const settingsPath = path.join(memoryDir, 'settings.json'); + if (fs.existsSync(settingsPath) && !force) return false; + + const defaultSettings = { + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(defaultSettings, null, 2)); + return true; +} + async function downloadModel(cacheDir: string, asJson: boolean): Promise { if (!asJson) { console.log('\n📥 Downloading embedding model...'); @@ -415,6 +494,15 @@ async function main() { // Write settings fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + // Install skills + const skillsResult = installSkills(projectDir, force, asJson); + + // Create default memory settings + const settingsCreated = createDefaultSettings(memoryDir, force); + if (!asJson && settingsCreated) { + console.log('\n⚙️ Default memory settings created'); + } + // Download embedding model let modelDownloaded = false; if (!skipModel) { @@ -428,6 +516,7 @@ async function main() { hooksAdded: hooksResult.added, hooksSkipped: hooksResult.skipped, hooksManualRequired: hooksResult.manualRequired, + skillsInstalled: skillsResult.installed, mcpConfigured: mcpResult.configured, modelDownloaded, message: 'Memory setup complete', @@ -448,6 +537,11 @@ async function main() { console.log(` Skipped: ${hooksResult.skipped.join(', ')}`); } + // Show skills status + if (skillsResult.installed.length > 0) { + console.log(`\n🎯 Skills: ${skillsResult.installed.join(', ')}`); + } + // Show manual action required if (hooksResult.manualRequired.length > 0) { console.log('\n⚠️ Manual review recommended:'); diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 51986a5..5dc258c 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import * as path from 'node:path'; import { MemoryHookService, createHookService } from '../service.js'; import { _setRunClaudePrintMockForTesting, resetAIEnrichmentCache } from '../ai-enrichment.js'; @@ -1018,6 +1018,174 @@ describe('MemoryHookService', () => { }); }); + // ===== Persistent Settings ===== + + describe('loadSettings / saveSettings', () => { + it('should return defaults when no settings file exists', async () => { + await service.initialize(); + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(true); + expect(settings.context.showPrompts).toBe(true); + expect(settings.context.showObservations).toBe(true); + expect(settings.context.showToolGuidance).toBe(true); + expect(settings.context.maxSummaries).toBe(3); + expect(settings.context.maxPrompts).toBe(10); + expect(settings.context.maxObservations).toBe(10); + }); + + it('should read settings from .claude/memory/settings.json', async () => { + await service.initialize(); + // Write a custom settings file + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + mkdirSync(path.dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ + context: { showSummaries: false, maxObservations: 25 }, + })); + + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(false); + expect(settings.context.maxObservations).toBe(25); + // Missing keys get defaults + expect(settings.context.showPrompts).toBe(true); + expect(settings.context.maxSummaries).toBe(3); + }); + + it('should return defaults on corrupted settings file', async () => { + await service.initialize(); + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + mkdirSync(path.dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, '{ invalid json'); + + const settings = service.loadSettings(); + expect(settings.context.showSummaries).toBe(true); + expect(settings.context.maxObservations).toBe(10); + }); + + it('should save settings to disk', async () => { + await service.initialize(); + const customSettings = { + context: { + showSummaries: false, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 5, + maxPrompts: 20, + maxObservations: 30, + }, + }; + service.saveSettings(customSettings); + + // Verify file contents + const settingsPath = path.join(TEST_DIR, '.claude', 'memory', 'settings.json'); + const saved = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(saved.context.showSummaries).toBe(false); + expect(saved.context.maxObservations).toBe(30); + }); + + it('should round-trip save and load', async () => { + await service.initialize(); + const original = { + context: { + showSummaries: false, + showPrompts: false, + showObservations: true, + showToolGuidance: true, + maxSummaries: 1, + maxPrompts: 5, + maxObservations: 15, + }, + }; + service.saveSettings(original); + const loaded = service.loadSettings(); + expect(loaded).toEqual(original); + }); + }); + + describe('getContext with settings', () => { + it('should use settings from disk', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + + // Save settings to disable summaries + service.saveSettings({ + context: { + showSummaries: false, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }); + + const context = await service.getContext('test-project'); + // Tool guidance should be absent when showToolGuidance=false + expect(context.markdown).not.toContain('Memory tools available'); + }); + + it('should respect configOverride over disk settings', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'a.ts' }, {}, TEST_DIR + ); + + // Disk settings: guidance enabled + service.saveSettings({ + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + }); + + // Override: disable guidance + const context = await service.getContext('test-project', { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: false, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }); + expect(context.markdown).not.toContain('Memory tools available'); + }); + + it('should limit observations by maxObservations setting', async () => { + await service.initSession('session-1', 'test-project'); + // Store 5 observations + for (let i = 0; i < 5; i++) { + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: `file${i}.ts` }, {}, TEST_DIR + ); + } + + // Set maxObservations=2 + service.saveSettings({ + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 2, + }, + }); + + const context = await service.getContext('test-project'); + expect(context.recentObservations.length).toBe(2); + }); + }); + describe('createHookService factory', () => { it('should create service with default config', () => { const svc = createHookService(TEST_DIR); diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 071331d..ac8ce56 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -22,7 +22,7 @@ * @module @agentkits/memory/hooks/cli */ -import { parseHookInput, formatResponse, STANDARD_RESPONSE, HookResult } from './types.js'; +import { parseHookInput, formatResponse, STANDARD_RESPONSE, HookResult, DEFAULT_MEMORY_SETTINGS, DEFAULT_CONTEXT_CONFIG } from './types.js'; import { createContextHook } from './context.js'; import { createSessionInitHook } from './session-init.js'; import { createObservationHook } from './observation.js'; @@ -72,7 +72,7 @@ async function main(): Promise { if (!event) { console.error('Usage: agentkits-memory-hook '); - console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session, lifecycle, lifecycle-stats, export, import'); + console.error('Events: context, session-init, observation, summarize, user-message, enrich, enrich-summary, embed-session, enrich-session, compress-session, lifecycle, lifecycle-stats, export, import, settings'); process.exit(1); } @@ -253,6 +253,48 @@ async function main(): Promise { process.exit(0); } + // Handle 'settings' command + // Usage: settings [key=value ...] [--reset] + if (event === 'settings') { + const cwdArg = process.argv[3] || process.cwd(); + const svc = new MemoryHookService(cwdArg); + await svc.initialize(); + try { + const settingsArgs = process.argv.slice(4); + + if (settingsArgs.includes('--reset')) { + svc.saveSettings({ ...DEFAULT_MEMORY_SETTINGS, context: { ...DEFAULT_CONTEXT_CONFIG } }); + console.log(JSON.stringify(DEFAULT_MEMORY_SETTINGS, null, 2)); + } else if (settingsArgs.length === 0) { + console.log(JSON.stringify(svc.loadSettings(), null, 2)); + } else { + const settings = svc.loadSettings(); + for (const arg of settingsArgs) { + const eqIndex = arg.indexOf('='); + if (eqIndex <= 0) continue; + const key = arg.slice(0, eqIndex); + const value = arg.slice(eqIndex + 1); + if (key in settings.context) { + const contextKey = key as keyof typeof settings.context; + const current = settings.context[contextKey]; + if (typeof current === 'boolean') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.context as any)[key] = value === 'true'; + } else if (typeof current === 'number') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.context as any)[key] = parseInt(value, 10); + } + } + } + svc.saveSettings(settings); + console.log(JSON.stringify(settings, null, 2)); + } + } finally { + await svc.shutdown(); + } + process.exit(0); + } + // Read stdin const stdin = await readStdin(); diff --git a/src/hooks/service.ts b/src/hooks/service.ts index cf60fc4..d4e1736 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -34,6 +34,8 @@ import { computeContentHash, ContextConfig, DEFAULT_CONTEXT_CONFIG, + MemorySettings, + DEFAULT_MEMORY_SETTINGS, LifecycleConfig, DEFAULT_LIFECYCLE_CONFIG, LifecycleResult, @@ -936,17 +938,50 @@ export class MemoryHookService { return rows.map(row => this.rowToObservation(row)); } + // ===== Settings ===== + + /** + * Load persistent settings from .claude/memory/settings.json + * Returns merged with defaults (missing keys get default values) + */ + loadSettings(): MemorySettings { + const settingsPath = path.join(path.dirname(this.dbPath), 'settings.json'); + try { + if (existsSync(settingsPath)) { + const raw = JSON.parse(readFileSync(settingsPath, 'utf-8')); + return { + context: { ...DEFAULT_CONTEXT_CONFIG, ...(raw.context || {}) }, + }; + } + } catch { + // Ignore parse errors, return defaults + } + return { ...DEFAULT_MEMORY_SETTINGS, context: { ...DEFAULT_CONTEXT_CONFIG } }; + } + + /** + * Save settings to .claude/memory/settings.json + */ + saveSettings(settings: MemorySettings): void { + const settingsPath = path.join(path.dirname(this.dbPath), 'settings.json'); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + // ===== Context Generation ===== /** * Get memory context for session start */ - async getContext(project: string): Promise { + async getContext(project: string, configOverride?: ContextConfig): Promise { await this.ensureInitialized(); + // Load persistent settings, allow runtime override + const settings = this.loadSettings(); + const config = configOverride || settings.context; + const recentObservations = await this.getRecentObservations( project, - this.config.maxContextObservations + config.maxObservations ); const previousSessions = await this.getRecentSessions( @@ -954,12 +989,12 @@ export class MemoryHookService { this.config.maxContextSessions ); - const userPrompts = await this.getRecentPrompts(project, 20); - const sessionSummaries = await this.getRecentSummaries(project, 5); + const userPrompts = await this.getRecentPrompts(project, config.maxPrompts); + const sessionSummaries = await this.getRecentSummaries(project, config.maxSummaries); - // Generate markdown + // Generate markdown with settings-driven config const markdown = this.formatContextMarkdown( - recentObservations, previousSessions, userPrompts, sessionSummaries, project + recentObservations, previousSessions, userPrompts, sessionSummaries, project, config ); return { diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 276e470..d16ea4f 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -719,6 +719,19 @@ export const DEFAULT_CONTEXT_CONFIG: ContextConfig = { maxObservations: 10, }; +/** + * Persistent memory settings stored in .claude/memory/settings.json + */ +export interface MemorySettings { + /** Context injection configuration */ + context: ContextConfig; +} + +/** Default memory settings */ +export const DEFAULT_MEMORY_SETTINGS: MemorySettings = { + context: DEFAULT_CONTEXT_CONFIG, +}; + /** * Generate observation ID */ From f6f48da135fe8812bf3c7137c1530fd90d410b74 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 11:52:31 +0900 Subject: [PATCH 13/21] feat: add methods to check for pending embedding, enrichment, and compression tasks; enhance session initialization --- src/hooks/__tests__/service.test.ts | 113 ++++++++++++++++++++++++++++ src/hooks/cli.ts | 20 ++++- src/hooks/context.ts | 13 ++++ src/hooks/service.ts | 46 +++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index 5dc258c..a601cac 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -1018,6 +1018,119 @@ describe('MemoryHookService', () => { }); }); + // ===== Embedding Reliability ===== + + describe('hasPendingEmbeddings', () => { + it('should return false when no pending tasks or missing embeddings', async () => { + await service.initialize(); + expect(service.hasPendingEmbeddings()).toBe(false); + }); + + it('should return true when pending embed tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // storeObservation auto-queues embed task + expect(service.hasPendingEmbeddings()).toBe(true); + }); + + it('should return true when observations have null embeddings', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Clear task queue but leave embedding null + service.db.prepare("DELETE FROM task_queue").run(); + expect(service.hasPendingEmbeddings()).toBe(true); + }); + + it('should return false when db not initialized', async () => { + // service.db is null before initialize + expect(service.hasPendingEmbeddings()).toBe(false); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initSession('session-1', 'test-project'); + await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Set all tasks to failed with max retries + service.db.prepare("UPDATE task_queue SET status = 'failed', retry_count = 3").run(); + // But observations still have null embeddings — should detect via DB scan + expect(service.hasPendingEmbeddings()).toBe(true); + }); + }); + + describe('hasPendingEnrichments', () => { + it('should return false when no pending enrich tasks', async () => { + await service.initialize(); + expect(service.hasPendingEnrichments()).toBe(false); + }); + + it('should return true when pending enrich tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + // Manually queue an enrich task + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('enrich', ?, 'observations', 'pending', datetime('now'))" + ).run(obs.id); + expect(service.hasPendingEnrichments()).toBe(true); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initialize(); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('enrich', 'obs-1', 'observations', 'failed', 3, datetime('now'))" + ).run(); + expect(service.hasPendingEnrichments()).toBe(false); + }); + + it('should return false when db not initialized', () => { + expect(service.hasPendingEnrichments()).toBe(false); + }); + }); + + describe('hasPendingCompressions', () => { + it('should return false when no pending compress tasks', async () => { + await service.initialize(); + expect(service.hasPendingCompressions()).toBe(false); + }); + + it('should return true when pending compress tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + const obs = await service.storeObservation( + 'session-1', 'test-project', 'Read', { file_path: 'test.ts' }, {}, TEST_DIR + ); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('compress', ?, 'observations', 'pending', datetime('now'))" + ).run(obs.id); + expect(service.hasPendingCompressions()).toBe(true); + }); + + it('should return true when pending digest tasks exist', async () => { + await service.initSession('session-1', 'test-project'); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, created_at) VALUES ('digest', 'session-1', 'sessions', 'pending', datetime('now'))" + ).run(); + expect(service.hasPendingCompressions()).toBe(true); + }); + + it('should ignore failed tasks at max retries', async () => { + await service.initialize(); + service.db.prepare( + "INSERT INTO task_queue (task_type, target_id, target_table, status, retry_count, created_at) VALUES ('compress', 'obs-1', 'observations', 'failed', 3, datetime('now'))" + ).run(); + expect(service.hasPendingCompressions()).toBe(false); + }); + + it('should return false when db not initialized', () => { + expect(service.hasPendingCompressions()).toBe(false); + }); + }); + // ===== Persistent Settings ===== describe('loadSettings / saveSettings', () => { diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index ac8ce56..2b9fd1f 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -105,7 +105,7 @@ async function main(): Promise { // Handle 'embed-session' command (no stdin, runs as background process) // Processes the SQLite embedding queue + any records missing embeddings. - // Loads model once, processes sequentially with batch limit. Usage: embed-session + // Loops until queue is empty (batch limit per iteration). Usage: embed-session if (event === 'embed-session') { const cwdArg = process.argv[3] || process.cwd(); const svc = new MemoryHookService(cwdArg); @@ -118,7 +118,11 @@ async function main(): Promise { const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); killTimer.unref(); try { - await svc.processEmbeddingQueue(); + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processEmbeddingQueue(); + } while (processed > 0); } finally { clearTimeout(killTimer); await svc.shutdown(); @@ -141,7 +145,11 @@ async function main(): Promise { const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); killTimer.unref(); try { - await svc.processEnrichmentQueue(); + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processEnrichmentQueue(); + } while (processed > 0); } finally { clearTimeout(killTimer); await svc.shutdown(); @@ -163,7 +171,11 @@ async function main(): Promise { const killTimer = setTimeout(() => { cleanup(); }, 5 * 60 * 1000); killTimer.unref(); try { - await svc.processCompressionQueue(); + // Loop until no more work — each call processes up to WORKER_BATCH_LIMIT items + let processed: number; + do { + processed = await svc.processCompressionQueue(); + } while (processed > 0); } finally { clearTimeout(killTimer); await svc.shutdown(); diff --git a/src/hooks/context.ts b/src/hooks/context.ts index d2f17f3..df67038 100644 --- a/src/hooks/context.ts +++ b/src/hooks/context.ts @@ -47,6 +47,19 @@ export class ContextHook implements EventHandler { // Initialize service await this.service.initialize(); + // Catch-up: spawn workers if stale pending tasks exist from previous sessions + try { + if (this.service.hasPendingEmbeddings()) { + this.service.ensureWorkerRunning(input.cwd, 'embed-session', 'embed-worker.lock'); + } + if (this.service.hasPendingEnrichments()) { + this.service.ensureWorkerRunning(input.cwd, 'enrich-session', 'enrich-worker.lock'); + } + if (this.service.hasPendingCompressions()) { + this.service.ensureWorkerRunning(input.cwd, 'compress-session', 'compress-worker.lock'); + } + } catch { /* non-critical — don't block context injection */ } + // Get context for this project const context = await this.service.getContext(input.project); const hasHistory = context.markdown && !context.markdown.includes('No previous session context'); diff --git a/src/hooks/service.ts b/src/hooks/service.ts index d4e1736..83a057c 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -906,6 +906,52 @@ export class MemoryHookService { return count; } + /** + * Check if there are pending embedding tasks or records missing embeddings. + * Used to decide whether to spawn the embed worker on session start. + */ + hasPendingEmbeddings(): boolean { + if (!this.db) return false; + // Check task_queue for pending embed tasks + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type = 'embed' AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + if (pending.cnt > 0) return true; + + // Check for records missing embeddings (lightweight count, limit 1) + for (const table of ['observations', 'user_prompts', 'session_summaries', 'session_digests']) { + try { + const missing = this.db.prepare( + `SELECT 1 FROM ${table} WHERE embedding IS NULL LIMIT 1` + ).get(); + if (missing) return true; + } catch { /* table might not exist */ } + } + return false; + } + + /** + * Check if there are pending enrichment tasks in the queue + */ + hasPendingEnrichments(): boolean { + if (!this.db) return false; + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type = 'enrich' AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + return pending.cnt > 0; + } + + /** + * Check if there are pending compression tasks in the queue + */ + hasPendingCompressions(): boolean { + if (!this.db) return false; + const pending = this.db.prepare( + "SELECT COUNT(*) as cnt FROM task_queue WHERE task_type IN ('compress', 'digest') AND status = 'pending' AND retry_count < ?" + ).get(MemoryHookService.MAX_TASK_RETRIES) as { cnt: number }; + return pending.cnt > 0; + } + /** * Get observations for a session */ From 68381d23bc52a9fed4ab05ab8d9b89f10e7e2862 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 13:47:12 +0900 Subject: [PATCH 14/21] feat: implement AI provider abstraction and configuration; enhance service and enrichment functions --- src/hooks/__tests__/ai-enrichment.test.ts | 45 ++++ src/hooks/__tests__/ai-provider.test.ts | 282 ++++++++++++++++++++++ src/hooks/__tests__/service.test.ts | 34 +++ src/hooks/ai-enrichment.ts | 167 +++++++------ src/hooks/ai-provider.ts | 236 ++++++++++++++++++ src/hooks/cli.ts | 11 +- src/hooks/service.ts | 10 +- src/hooks/types.ts | 2 + 8 files changed, 699 insertions(+), 88 deletions(-) create mode 100644 src/hooks/__tests__/ai-provider.test.ts create mode 100644 src/hooks/ai-provider.ts diff --git a/src/hooks/__tests__/ai-enrichment.test.ts b/src/hooks/__tests__/ai-enrichment.test.ts index 2e4b4c3..eaa40fc 100644 --- a/src/hooks/__tests__/ai-enrichment.test.ts +++ b/src/hooks/__tests__/ai-enrichment.test.ts @@ -26,6 +26,7 @@ import { generateSessionDigestWithAI, _setRunClaudePrintMockForTesting, _setCliAvailableForTesting, + setAIProviderConfig, } from '../ai-enrichment.js'; describe('AI Enrichment Module', () => { @@ -1015,4 +1016,48 @@ describe('AI Enrichment Module', () => { expect(elapsed).toBeLessThan(5000); }); }); + + // ===== Provider Config ===== + + describe('setAIProviderConfig', () => { + it('should accept provider config without error', () => { + expect(() => setAIProviderConfig({ provider: 'openai', apiKey: 'test' })).not.toThrow(); + }); + + it('should accept undefined to reset config', () => { + setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); + expect(() => setAIProviderConfig(undefined)).not.toThrow(); + }); + + it('should force re-resolution so next enrichment uses new provider', async () => { + // Set to openai with no API key → provider unavailable → enrichment returns null + setAIProviderConfig({ provider: 'openai' }); + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{}', '{}'); + expect(result).toBeNull(); + }); + + it('should not affect mock-based testing', async () => { + setAIProviderConfig({ provider: 'openai', apiKey: 'test' }); + _setRunClaudePrintMockForTesting(() => JSON.stringify({ + subtitle: 'Test subtitle', + narrative: 'Test narrative about something.', + facts: ['Fact 1'], + concepts: ['concept1'], + confidence: 0.9, + })); + delete process.env.AGENTKITS_AI_ENRICHMENT; + const result = await enrichWithAI('Read', '{"file_path":"test.ts"}', 'content'); + expect(result).not.toBeNull(); + expect(result!.subtitle).toBe('Test subtitle'); + }); + + it('should be cleared by resetAIEnrichmentCache', () => { + setAIProviderConfig({ provider: 'gemini', apiKey: 'key' }); + resetAIEnrichmentCache(); + // After reset, should use default provider (claude-cli) + // No error should occur + expect(() => enrichWithAI('Read', '{}', '{}')).not.toThrow(); + }); + }); }); diff --git a/src/hooks/__tests__/ai-provider.test.ts b/src/hooks/__tests__/ai-provider.test.ts new file mode 100644 index 0000000..3076293 --- /dev/null +++ b/src/hooks/__tests__/ai-provider.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for AI Provider abstraction + * + * @module @agentkits/memory/hooks/__tests__/ai-provider.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + resolveAIProvider, + createClaudeCliProvider, + createOpenAIProvider, + createGeminiProvider, + type AIProviderConfig, +} from '../ai-provider.js'; + +describe('AI Provider', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clean env vars before each test + delete process.env.AGENTKITS_AI_PROVIDER; + delete process.env.AGENTKITS_AI_API_KEY; + delete process.env.AGENTKITS_AI_BASE_URL; + delete process.env.AGENTKITS_AI_MODEL; + }); + + afterEach(() => { + // Restore env + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + // ===== resolveAIProvider ===== + + describe('resolveAIProvider', () => { + it('should default to claude-cli when no config and no env', () => { + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + }); + + it('should use settings config provider', () => { + const config: AIProviderConfig = { provider: 'openai', apiKey: 'test-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('openai'); + }); + + it('should use gemini provider from settings', () => { + const config: AIProviderConfig = { provider: 'gemini', apiKey: 'gemini-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('gemini'); + }); + + it('should override settings with env var AGENTKITS_AI_PROVIDER', () => { + process.env.AGENTKITS_AI_PROVIDER = 'gemini'; + process.env.AGENTKITS_AI_API_KEY = 'env-key'; + const config: AIProviderConfig = { provider: 'openai', apiKey: 'settings-key' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('gemini'); + }); + + it('should merge env API key with settings provider', () => { + process.env.AGENTKITS_AI_API_KEY = 'env-key'; + const config: AIProviderConfig = { provider: 'openai' }; + const provider = resolveAIProvider(config); + expect(provider.name).toBe('openai'); + // Provider should be available because env key is set + expect(provider.isAvailable()).toBe(true); + }); + + it('should fall back to claude-cli for unknown provider', () => { + process.env.AGENTKITS_AI_PROVIDER = 'unknown-provider'; + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + }); + + it('should use env model override', () => { + process.env.AGENTKITS_AI_MODEL = 'custom-model'; + const provider = resolveAIProvider(); + expect(provider.name).toBe('claude-cli'); + // Can't directly test model, but it shouldn't throw + }); + }); + + // ===== Claude CLI Provider ===== + + describe('createClaudeCliProvider', () => { + it('should create a provider with name claude-cli', () => { + const provider = createClaudeCliProvider('haiku'); + expect(provider.name).toBe('claude-cli'); + }); + + it('should cache isAvailable result', () => { + const provider = createClaudeCliProvider('haiku'); + // First call checks CLI, second should use cache + const first = provider.isAvailable(); + const second = provider.isAvailable(); + expect(first).toBe(second); + }); + + it('should return null from run when CLI is not available', async () => { + const provider = createClaudeCliProvider('nonexistent-model'); + // execFileSync will throw for invalid claude args, provider catches and returns null + const result = await provider.run('test prompt', 'system', 5000); + // On CI or machines without claude CLI, this returns null + expect(result === null || typeof result === 'string').toBe(true); + }); + }); + + // ===== OpenAI Provider ===== + + describe('createOpenAIProvider', () => { + it('should return unavailable when no API key', () => { + const provider = createOpenAIProvider('', 'https://api.openai.com/v1', 'gpt-4o-mini'); + expect(provider.isAvailable()).toBe(false); + }); + + it('should return available when API key is set', () => { + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + expect(provider.isAvailable()).toBe(true); + expect(provider.name).toBe('openai'); + }); + + it('should return null when fetch fails', async () => { + // Mock fetch to simulate network error + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should return null when response is not ok', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should parse successful response correctly', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: '{"subtitle": "test"}' } }], + }), + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBe('{"subtitle": "test"}'); + }); + + it('should send correct request format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'result' } }] }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + await provider.run('my prompt', 'my system', 10000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.openai.com/v1/chat/completions'); + expect(options.method).toBe('POST'); + expect(options.headers['Authorization']).toBe('Bearer sk-test'); + + const body = JSON.parse(options.body); + expect(body.model).toBe('gpt-4o-mini'); + expect(body.messages).toEqual([ + { role: 'system', content: 'my system' }, + { role: 'user', content: 'my prompt' }, + ]); + expect(body.temperature).toBe(0.3); + expect(body.max_tokens).toBe(1024); + }); + + it('should strip trailing slash from base URL', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createOpenAIProvider('sk-test', 'https://openrouter.ai/api/v1/', 'model'); + await provider.run('prompt', 'system', 5000); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); + }); + + it('should return null when response has no choices', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [] }), + })); + const provider = createOpenAIProvider('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + }); + + // ===== Gemini Provider ===== + + describe('createGeminiProvider', () => { + it('should return unavailable when no API key', () => { + const provider = createGeminiProvider('', 'gemini-2.0-flash'); + expect(provider.isAvailable()).toBe(false); + }); + + it('should return available when API key is set', () => { + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + expect(provider.isAvailable()).toBe(true); + expect(provider.name).toBe('gemini'); + }); + + it('should return null when fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should parse successful response correctly', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: '{"result": "ok"}' }] } }], + }), + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBe('{"result": "ok"}'); + }); + + it('should send correct Gemini API format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: 'result' }] } }], + }), + }); + vi.stubGlobal('fetch', mockFetch); + + const provider = createGeminiProvider('gemini-key', 'gemini-2.0-flash'); + await provider.run('my prompt', 'my system', 10000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain('generativelanguage.googleapis.com'); + expect(url).toContain('gemini-2.0-flash'); + expect(url).toContain('key=gemini-key'); + + const body = JSON.parse(options.body); + expect(body.system_instruction).toEqual({ parts: [{ text: 'my system' }] }); + expect(body.contents).toEqual([{ parts: [{ text: 'my prompt' }] }]); + expect(body.generationConfig.temperature).toBe(0.3); + expect(body.generationConfig.maxOutputTokens).toBe(1024); + }); + + it('should return null when response has no candidates', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ candidates: [] }), + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + + it('should return null on non-200 response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + })); + const provider = createGeminiProvider('test-key', 'gemini-2.0-flash'); + const result = await provider.run('test', 'system', 5000); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/hooks/__tests__/service.test.ts b/src/hooks/__tests__/service.test.ts index a601cac..78a89ee 100644 --- a/src/hooks/__tests__/service.test.ts +++ b/src/hooks/__tests__/service.test.ts @@ -1213,6 +1213,40 @@ describe('MemoryHookService', () => { const loaded = service.loadSettings(); expect(loaded).toEqual(original); }); + + it('should save and load aiProvider settings', async () => { + await service.initialize(); + const settings = { + context: { + showSummaries: true, + showPrompts: true, + showObservations: true, + showToolGuidance: true, + maxSummaries: 3, + maxPrompts: 10, + maxObservations: 10, + }, + aiProvider: { + provider: 'openai' as const, + apiKey: 'sk-test-key', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'anthropic/claude-3.5-haiku', + }, + }; + service.saveSettings(settings); + const loaded = service.loadSettings(); + expect(loaded.aiProvider).toBeDefined(); + expect(loaded.aiProvider!.provider).toBe('openai'); + expect(loaded.aiProvider!.apiKey).toBe('sk-test-key'); + expect(loaded.aiProvider!.baseUrl).toBe('https://openrouter.ai/api/v1'); + expect(loaded.aiProvider!.model).toBe('anthropic/claude-3.5-haiku'); + }); + + it('should return undefined aiProvider when not set in settings', async () => { + await service.initialize(); + const loaded = service.loadSettings(); + expect(loaded.aiProvider).toBeUndefined(); + }); }); describe('getContext with settings', () => { diff --git a/src/hooks/ai-enrichment.ts b/src/hooks/ai-enrichment.ts index 83b53df..65efd7e 100644 --- a/src/hooks/ai-enrichment.ts +++ b/src/hooks/ai-enrichment.ts @@ -1,16 +1,15 @@ /** * AI Enrichment for Observations and Session Summaries * - * Uses `claude --print` CLI to generate richer subtitle, narrative, - * facts, and concepts from tool observations, and to enhance session - * summaries using transcript data. This avoids the Claude Agent SDK's - * `query()` which creates visible sub-conversations in the Claude Code UI. - * Falls back to template-based extraction when `claude` CLI is not available. + * Uses pluggable AI providers (Claude CLI, OpenAI-compatible, Gemini) to + * generate richer subtitle, narrative, facts, and concepts from tool + * observations, and to enhance session summaries using transcript data. + * Falls back to template-based extraction when the provider is not available. * * @module @agentkits/memory/hooks/ai-enrichment */ -import { execFileSync } from 'node:child_process'; +import { resolveAIProvider, type AIProviderConfig, type ResolvedProvider } from './ai-provider.js'; /** * Enriched observation data from AI extraction @@ -27,21 +26,43 @@ export interface EnrichedObservation { /** * Environment variable to enable/disable AI enrichment. * Set AGENTKITS_AI_ENRICHMENT=true to enable, false to disable. - * When not set, defaults to auto-detect (uses AI if CLI available). + * When not set, defaults to auto-detect (uses AI if provider available). */ const AI_ENRICHMENT_ENV_KEY = 'AGENTKITS_AI_ENRICHMENT'; -/** Cached CLI availability */ -let _cliAvailable: boolean | null = null; +/** Resolved provider (cached at module level, lazy-initialized) */ +let _resolvedProvider: ResolvedProvider | null = null; -/** Mock function for testing (replaces runClaudePrint when set) */ +/** Provider config from settings.json (set by service.ts) */ +let _providerConfig: AIProviderConfig | undefined; + +/** Mock function for testing (replaces provider.run when set) */ let _mockRunClaudePrint: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null = null; +/** + * Set AI provider config from settings.json. + * Called by service.ts after loading settings. Forces re-resolution on next use. + */ +export function setAIProviderConfig(config?: AIProviderConfig): void { + _providerConfig = config; + _resolvedProvider = null; // force re-resolution +} + +/** + * Get the resolved AI provider (lazy-initialized, cached). + */ +function getProvider(): ResolvedProvider { + if (!_resolvedProvider) { + _resolvedProvider = resolveAIProvider(_providerConfig); + } + return _resolvedProvider; +} + /** * Check if AI enrichment is enabled via environment variable * - 'true' / '1' → force enable * - 'false' / '0' → force disable - * - not set → auto-detect (try CLI, fallback to template) + * - not set → auto-detect (try provider, fallback to template) */ function isEnvEnabled(): boolean | null { const value = process.env[AI_ENRICHMENT_ENV_KEY]; @@ -50,71 +71,41 @@ function isEnvEnabled(): boolean | null { } /** - * Synchronous check: is AI enrichment potentially enabled? - * Used by observation hook to decide whether to spawn background process. - * Does NOT check CLI availability (that's async). Just checks env var. + * Check if the current AI provider is available. + * Respects AGENTKITS_AI_ENRICHMENT env var override. */ -export function isAIEnrichmentEnabled(): boolean { +function isProviderAvailable(): boolean { const envEnabled = isEnvEnabled(); if (envEnabled === false) return false; - // If explicitly enabled or auto-detect, optimistically return true. - // The background process will handle CLI availability check. - return true; + + // When mock is set, provider is "available" + if (_mockRunClaudePrint) return true; + + return getProvider().isAvailable(); } /** - * Run a prompt through `claude --print` and return the raw text result. - * Uses --print mode which doesn't create a visible conversation. - * When a mock is set (testing), delegates to the mock instead. + * Run a prompt through the current AI provider. + * Uses mock if set (testing), otherwise delegates to the resolved provider. */ -function runClaudePrint(prompt: string, systemPrompt: string, timeoutMs: number): string | null { - // Use mock if set (testing) +async function runProvider(prompt: string, systemPrompt: string, timeoutMs: number): Promise { if (_mockRunClaudePrint) { return _mockRunClaudePrint(prompt, systemPrompt, timeoutMs); } - - try { - const result = execFileSync('claude', [ - '--print', - '--model', 'haiku', - '--system-prompt', systemPrompt, - '--max-turns', '1', - '--no-input', - '-p', prompt, - ], { - encoding: 'utf-8', - timeout: timeoutMs, - stdio: ['pipe', 'pipe', 'ignore'], // stdin pipe, stdout pipe, stderr ignore - }); - return result.trim() || null; - } catch { - return null; - } + return getProvider().run(prompt, systemPrompt, timeoutMs); } /** - * Check if `claude` CLI is available and cache the result. - * Env override (false/0) always takes priority over cache. + * Synchronous check: is AI enrichment potentially enabled? + * Used by observation hook to decide whether to spawn background process. + * Does NOT check provider availability (that may be slow). Just checks env var. */ -function isClaudeCliAvailable(): boolean { - // Env override always wins — even over cached/mocked state +export function isAIEnrichmentEnabled(): boolean { const envEnabled = isEnvEnabled(); if (envEnabled === false) return false; - - if (_cliAvailable !== null) return _cliAvailable; - - try { - execFileSync('claude', ['--version'], { - encoding: 'utf-8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'ignore'], - }); - _cliAvailable = true; - return true; - } catch { - _cliAvailable = false; - return false; - } + // If explicitly enabled or auto-detect, optimistically return true. + // The background process will handle provider availability check. + return true; } /** @@ -191,10 +182,9 @@ export function parseAIResponse(text: string): EnrichedObservation | null { } /** - * Enrich an observation using `claude --print` CLI. + * Enrich an observation using the configured AI provider. * - * Uses --print mode to avoid creating visible sub-conversations. - * Returns enriched data if CLI is available and succeeds, + * Returns enriched data if provider is available and succeeds, * or null to signal fallback to template-based extraction. */ export async function enrichWithAI( @@ -203,13 +193,13 @@ export async function enrichWithAI( toolResponse: string, timeoutMs: number = 15000 ): Promise { - if (!isClaudeCliAvailable()) return null; + if (!isProviderAvailable()) return null; try { const prompt = buildExtractionPrompt(toolName, toolInput, toolResponse); const systemPrompt = 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.'; - const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseAIResponse(resultText); } catch { @@ -218,29 +208,40 @@ export async function enrichWithAI( } /** - * Check if AI enrichment is available (`claude` CLI installed) + * Check if AI enrichment is available (provider configured and reachable) */ export async function isAIEnrichmentAvailable(): Promise { - return isClaudeCliAvailable(); + return isProviderAvailable(); } /** - * Reset cached CLI availability (for testing) + * Reset cached provider and mock state (for testing) */ export function resetAIEnrichmentCache(): void { - _cliAvailable = null; + _resolvedProvider = null; + _providerConfig = undefined; _mockRunClaudePrint = null; } /** - * Override CLI availability for testing (inject mock) + * Override provider availability for testing (inject mock). + * Sets the mock which makes isProviderAvailable() return true. */ export function _setCliAvailableForTesting(available: boolean): void { - _cliAvailable = available; + if (available) { + // Set a pass-through mock that marks provider as available + if (!_mockRunClaudePrint) { + _mockRunClaudePrint = () => null; + } + } else { + _mockRunClaudePrint = null; + // Force provider to be unavailable by clearing cache + _resolvedProvider = null; + } } /** - * Inject a mock for runClaudePrint (for testing). + * Inject a mock for the AI provider run function (for testing). * The mock receives (prompt, systemPrompt, timeoutMs) and returns string | null. * Pass null to clear the mock. */ @@ -248,9 +249,6 @@ export function _setRunClaudePrintMockForTesting( fn: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null ): void { _mockRunClaudePrint = fn; - if (fn) { - _cliAvailable = true; // Mock implies CLI is "available" - } } // ===== Session Summary Enrichment ===== @@ -334,24 +332,23 @@ export function parseSummaryResponse(text: string): EnrichedSummary | null { } /** - * Enrich a session summary using `claude --print` CLI. + * Enrich a session summary using the configured AI provider. * * Takes template-based summary + last assistant message from transcript, * returns AI-enhanced completed/nextSteps fields. - * Uses --print mode to avoid creating visible sub-conversations. */ export async function enrichSummaryWithAI( templateSummary: string, lastAssistantMessage: string, timeoutMs: number = 20000 ): Promise { - if (!isClaudeCliAvailable()) return null; + if (!isProviderAvailable()) return null; try { const prompt = buildSummaryPrompt(templateSummary, lastAssistantMessage); const systemPrompt = 'You are a session summary analyzer. Produce concise, accurate session summaries. Return only valid JSON.'; - const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseSummaryResponse(resultText); } catch { @@ -413,7 +410,7 @@ export function parseCompressionResponse(text: string): CompressedObservation | } /** - * Compress a single observation using `claude --print` CLI. + * Compress a single observation using the configured AI provider. * Returns a dense 50-150 char summary suitable for context injection. */ export async function compressObservationWithAI( @@ -424,13 +421,13 @@ export async function compressObservationWithAI( narrative?: string, timeoutMs: number = 10000 ): Promise { - if (!isClaudeCliAvailable()) return null; + if (!isProviderAvailable()) return null; try { const prompt = buildCompressionPrompt(toolName, toolInput, toolResponse, subtitle, narrative); const systemPrompt = 'You are a data compressor. Produce the shortest possible accurate summary. Return only valid JSON.'; - const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseCompressionResponse(resultText); } catch { @@ -493,7 +490,7 @@ export function parseSessionDigestResponse(text: string): SessionDigest | null { } /** - * Generate a session-level digest using `claude --print` CLI. + * Generate a session-level digest using the configured AI provider. * Compresses an entire session into a 200-500 char digest. */ export async function generateSessionDigestWithAI( @@ -503,13 +500,13 @@ export async function generateSessionDigestWithAI( filesModified: string[], timeoutMs: number = 15000 ): Promise { - if (!isClaudeCliAvailable()) return null; + if (!isProviderAvailable()) return null; try { const prompt = buildSessionDigestPrompt(request, observationSummaries, completed, filesModified); const systemPrompt = 'You are a session compressor. Produce the shortest possible accurate digest of a coding session. Return only valid JSON.'; - const resultText = runClaudePrint(prompt, systemPrompt, timeoutMs); + const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseSessionDigestResponse(resultText); } catch { diff --git a/src/hooks/ai-provider.ts b/src/hooks/ai-provider.ts new file mode 100644 index 0000000..3444d75 --- /dev/null +++ b/src/hooks/ai-provider.ts @@ -0,0 +1,236 @@ +/** + * AI Provider Abstraction + * + * Pluggable providers for AI enrichment/compression operations. + * Supports Claude CLI (default), OpenAI-compatible APIs, and Google Gemini. + * + * @module @agentkits/memory/hooks/ai-provider + */ + +import { execFileSync } from 'node:child_process'; + +// ===== Types ===== + +/** + * AI provider configuration. + * Stored in `.claude/memory/settings.json` under the `aiProvider` key. + */ +export interface AIProviderConfig { + /** Provider type */ + provider: 'claude-cli' | 'openai' | 'gemini'; + /** API key (for HTTP providers; omit for claude-cli) */ + apiKey?: string; + /** Base URL for OpenAI-compatible API (default: https://api.openai.com/v1) */ + baseUrl?: string; + /** Model name (default varies by provider) */ + model?: string; +} + +/** Default provider configuration */ +export const DEFAULT_AI_PROVIDER_CONFIG: AIProviderConfig = { + provider: 'claude-cli', +}; + +/** + * Provider function contract — takes prompt + system prompt + timeout, + * returns raw text response or null on failure. + */ +export type AIProviderFn = ( + prompt: string, + systemPrompt: string, + timeoutMs: number +) => Promise; + +/** Synchronous availability check (best-effort). */ +export type AIProviderAvailableCheck = () => boolean; + +/** Resolved provider with run function and availability check. */ +export interface ResolvedProvider { + run: AIProviderFn; + isAvailable: AIProviderAvailableCheck; + name: string; +} + +// ===== Provider: Claude CLI ===== + +/** + * Create a provider that uses `claude --print` CLI. + * Wraps the existing execFileSync-based approach. + */ +export function createClaudeCliProvider(model: string): ResolvedProvider { + let cliAvailable: boolean | null = null; + + const isAvailable: AIProviderAvailableCheck = () => { + if (cliAvailable !== null) return cliAvailable; + try { + execFileSync('claude', ['--version'], { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'ignore'], + }); + cliAvailable = true; + } catch { + cliAvailable = false; + } + return cliAvailable; + }; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const result = execFileSync('claude', [ + '--print', + '--model', model, + '--system-prompt', systemPrompt, + '--max-turns', '1', + '--no-input', + '-p', prompt, + ], { + encoding: 'utf-8', + timeout: timeoutMs, + stdio: ['pipe', 'pipe', 'ignore'], + }); + return result.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'claude-cli' }; +} + +// ===== Provider: OpenAI-Compatible ===== + +/** + * Create a provider that calls any OpenAI-compatible chat completions API. + * Covers: OpenRouter, GLM/ZhipuAI, Ollama, LM Studio, vLLM, Together.ai, etc. + */ +export function createOpenAIProvider( + apiKey: string, + baseUrl: string, + model: string +): ResolvedProvider { + const isAvailable: AIProviderAvailableCheck = () => !!apiKey; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt }, + ], + temperature: 0.3, + max_tokens: 1024, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) return null; + + const data = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + return data.choices?.[0]?.message?.content?.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'openai' }; +} + +// ===== Provider: Google Gemini ===== + +/** + * Create a provider that calls Google's Gemini API. + * Uses the generateContent endpoint with system_instruction support. + */ +export function createGeminiProvider( + apiKey: string, + model: string +): ResolvedProvider { + const isAvailable: AIProviderAvailableCheck = () => !!apiKey; + + const run: AIProviderFn = async (prompt, systemPrompt, timeoutMs) => { + try { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.3, + maxOutputTokens: 1024, + }, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + + if (!response.ok) return null; + + const data = await response.json() as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null; + } catch { + return null; + } + }; + + return { run, isAvailable, name: 'gemini' }; +} + +// ===== Provider Resolver ===== + +/** + * Resolve which AI provider to use. + * + * Resolution order: + * 1. Environment variables (AGENTKITS_AI_PROVIDER, etc.) — override everything + * 2. Settings config (from settings.json) — persistent user preference + * 3. Default: claude-cli + */ +export function resolveAIProvider(settingsConfig?: AIProviderConfig): ResolvedProvider { + // Env vars override settings + const envProvider = process.env.AGENTKITS_AI_PROVIDER; + const envApiKey = process.env.AGENTKITS_AI_API_KEY; + const envBaseUrl = process.env.AGENTKITS_AI_BASE_URL; + const envModel = process.env.AGENTKITS_AI_MODEL; + + // Merge: env > settings > defaults + const provider = envProvider || settingsConfig?.provider || 'claude-cli'; + const apiKey = envApiKey || settingsConfig?.apiKey || ''; + const baseUrl = envBaseUrl || settingsConfig?.baseUrl || 'https://api.openai.com/v1'; + + switch (provider) { + case 'openai': + return createOpenAIProvider( + apiKey, + baseUrl, + envModel || settingsConfig?.model || 'gpt-4o-mini' + ); + + case 'gemini': + return createGeminiProvider( + apiKey, + envModel || settingsConfig?.model || 'gemini-2.0-flash' + ); + + case 'claude-cli': + default: + return createClaudeCliProvider( + envModel || settingsConfig?.model || 'haiku' + ); + } +} diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 2b9fd1f..89e76fd 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -286,7 +286,16 @@ async function main(): Promise { if (eqIndex <= 0) continue; const key = arg.slice(0, eqIndex); const value = arg.slice(eqIndex + 1); - if (key in settings.context) { + + // Handle aiProvider.* keys (e.g., aiProvider.provider=openai) + if (key.startsWith('aiProvider.')) { + const subKey = key.slice('aiProvider.'.length); + if (!settings.aiProvider) { + settings.aiProvider = { provider: 'claude-cli' }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings.aiProvider as any)[subKey] = value; + } else if (key in settings.context) { const contextKey = key as keyof typeof settings.context; const current = settings.context[contextKey]; if (typeof current === 'boolean') { diff --git a/src/hooks/service.ts b/src/hooks/service.ts index 83a057c..78573d9 100644 --- a/src/hooks/service.ts +++ b/src/hooks/service.ts @@ -29,7 +29,6 @@ import { detectIntent, extractIntents, extractCodeDiffs, - formatDiffFact, truncate, computeContentHash, ContextConfig, @@ -44,7 +43,7 @@ import { ExportSession, ImportResult, } from './types.js'; -import { enrichWithAI, enrichSummaryWithAI, compressObservationWithAI, generateSessionDigestWithAI } from './ai-enrichment.js'; +import { enrichWithAI, enrichSummaryWithAI, compressObservationWithAI, generateSessionDigestWithAI, setAIProviderConfig } from './ai-enrichment.js'; /** * Memory Hook Service Configuration @@ -114,6 +113,12 @@ export class MemoryHookService { // Create schema this.createSchema(); + // Configure AI provider from persistent settings + const settings = this.loadSettings(); + if (settings.aiProvider) { + setAIProviderConfig(settings.aiProvider); + } + this.initialized = true; } @@ -997,6 +1002,7 @@ export class MemoryHookService { const raw = JSON.parse(readFileSync(settingsPath, 'utf-8')); return { context: { ...DEFAULT_CONTEXT_CONFIG, ...(raw.context || {}) }, + aiProvider: raw.aiProvider || undefined, }; } } catch { diff --git a/src/hooks/types.ts b/src/hooks/types.ts index d16ea4f..e6b7381 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -725,6 +725,8 @@ export const DEFAULT_CONTEXT_CONFIG: ContextConfig = { export interface MemorySettings { /** Context injection configuration */ context: ContextConfig; + /** AI provider configuration (for enrichment/compression) */ + aiProvider?: import('./ai-provider.js').AIProviderConfig; } /** Default memory settings */ From fcb3679a015dde95382af348d0abc8f653a9d926 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 13:58:19 +0900 Subject: [PATCH 15/21] feat: Add rules generator for platform-specific memory instructions - Implemented a new rules generator in `rules-generator.ts` to create platform-specific rules files for AI coding assistants. - The generator includes memory workflow instructions and token efficiency rules. - Added functionality to install or update rules files in the project directory. refactor: Enhance setup process for multiple AI platforms - Updated `setup.ts` to support additional platforms: Cline and OpenCode. - Improved MCP server configuration to handle different formats for various platforms. - Integrated rules file installation into the setup process for supported platforms. test: Add unit tests for platform adapters - Created comprehensive tests for `ClaudeCodeAdapter`, `OpenCodeAdapter`, and `GenericAdapter`. - Ensured consistent behavior across adapters and validated input/output formats. feat: Implement platform adapters for hook handling - Developed `ClaudeCodeAdapter`, `OpenCodeAdapter`, and `GenericAdapter` to normalize input and output for different platforms. - Added a `resolveAdapter` function to select the appropriate adapter based on environment variables. chore: Update CLI to utilize platform adapters - Modified the CLI to read input using the resolved adapter and output responses accordingly. - Ensured backward compatibility with existing formats while supporting new adapters. --- README.md | 216 +++++++++--- skills/{recall => memory}/SKILL.md | 4 +- src/cli/__tests__/platforms.test.ts | 192 +++++++++++ src/cli/__tests__/rules-generator.test.ts | 139 ++++++++ src/cli/platforms.ts | 149 ++++++++ src/cli/rules-generator.ts | 126 +++++++ src/cli/setup.ts | 246 ++++++++----- src/hooks/__tests__/adapters.test.ts | 399 ++++++++++++++++++++++ src/hooks/adapters/claude-code-adapter.ts | 74 ++++ src/hooks/adapters/generic-adapter.ts | 75 ++++ src/hooks/adapters/index.ts | 11 + src/hooks/adapters/opencode-adapter.ts | 80 +++++ src/hooks/adapters/platform-adapter.ts | 68 ++++ src/hooks/cli.ts | 14 +- 14 files changed, 1655 insertions(+), 138 deletions(-) rename skills/{recall => memory}/SKILL.md (98%) create mode 100644 src/cli/__tests__/platforms.test.ts create mode 100644 src/cli/__tests__/rules-generator.test.ts create mode 100644 src/cli/platforms.ts create mode 100644 src/cli/rules-generator.ts create mode 100644 src/hooks/__tests__/adapters.test.ts create mode 100644 src/hooks/adapters/claude-code-adapter.ts create mode 100644 src/hooks/adapters/generic-adapter.ts create mode 100644 src/hooks/adapters/index.ts create mode 100644 src/hooks/adapters/opencode-adapter.ts create mode 100644 src/hooks/adapters/platform-adapter.ts diff --git a/README.md b/README.md index 9d4d318..35aeba8 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ License Claude Code Cursor - Copilot Windsurf Cline + OpenCode

@@ -33,9 +33,10 @@

Quick Start • - Web ViewerFeatures • - Ecosystem • + Platforms • + CLI • + Web Vieweragentkits.net

@@ -48,11 +49,15 @@ | **100% Local** | All data stays on your machine. No cloud, no API keys, no accounts | | **Blazing Fast** | Native SQLite (better-sqlite3) = instant queries, zero latency | | **Zero Config** | Works out of the box. No database setup required | -| **Cross-Platform** | Windows, macOS, Linux - same code, same speed | -| **MCP Server** | `memory_save`, `memory_search`, `memory_recall`, `memory_list`, `memory_status` | -| **Web Viewer** | Browser UI to view, add, edit, delete memories | -| **Vector Search** | Optional HNSW semantic similarity (no external service) | -| **Auto-Capture** | Hooks for session context, tool usage, summaries | +| **Multi-Platform** | Claude Code, Cursor, Windsurf, Cline, OpenCode — one setup command | +| **MCP Server** | 9 tools: save, search, timeline, details, recall, list, update, delete, status | +| **Auto-Capture** | Hooks capture session context, tool usage, summaries automatically | +| **AI Enrichment** | Background workers enrich observations with AI-generated summaries | +| **Vector Search** | HNSW semantic similarity with multilingual embeddings (100+ languages) | +| **Web Viewer** | Browser UI to view, search, add, edit, delete memories | +| **3-Layer Search** | Progressive disclosure saves ~87% tokens vs fetching everything | +| **Lifecycle Mgmt** | Auto-compress, archive, and clean up old sessions | +| **Export/Import** | Backup and restore memories as JSON | --- @@ -94,40 +99,58 @@ Generate and manage vector embeddings for semantic search. ## Quick Start -### 1. Install +### 1. Setup (Recommended) ```bash -npm install @aitytech/agentkits-memory +npx agentkits-memory-setup ``` -### 2. Configure MCP Server +This auto-detects your platform and configures everything: MCP server, hooks (Claude Code/OpenCode), rules files (Cursor/Windsurf/Cline), and downloads the embedding model. + +**Target a specific platform:** + +```bash +npx agentkits-memory-setup --platform=cursor +npx agentkits-memory-setup --platform=windsurf,cline +npx agentkits-memory-setup --platform=all +``` -Add to your `.mcp.json` (or `.claude/.mcp.json`): +### 2. Manual MCP Configuration (Alternative) + +If you prefer manual setup, add to your MCP config: ```json { "mcpServers": { "memory": { "command": "npx", - "args": ["agentkits-memory-server"] + "args": ["-y", "agentkits-memory-server"] } } } ``` -### 3. Use Memory Tools +Config file locations: +- **Claude Code**: `.claude/settings.json` (embedded in `mcpServers` key) +- **Cursor**: `.cursor/mcp.json` +- **Windsurf**: `.windsurf/mcp.json` +- **Cline / OpenCode**: `.mcp.json` (project root) + +### 3. MCP Tools Once configured, your AI assistant can use these tools: | Tool | Description | |------|-------------| +| `memory_status` | Check memory system status (call first!) | | `memory_save` | Save decisions, patterns, errors, or context | -| `memory_search` | **[Step 1]** Search memories - returns lightweight index | -| `memory_timeline` | **[Step 2]** Get context around a memory | +| `memory_search` | **[Step 1]** Search index — lightweight IDs + titles (~50 tokens/result) | +| `memory_timeline` | **[Step 2]** Get temporal context around a memory | | `memory_details` | **[Step 3]** Get full content for specific IDs | -| `memory_recall` | Quick recall - returns full content (legacy) | +| `memory_recall` | Quick topic overview — grouped summary | | `memory_list` | List recent memories | -| `memory_status` | Check memory system status | +| `memory_update` | Update existing memory content or tags | +| `memory_delete` | Remove outdated memories | --- @@ -186,20 +209,38 @@ memory_details({ ids: ["abc"] }) ## CLI Commands ```bash +# One-command setup (auto-detects platform) +npx agentkits-memory-setup +npx agentkits-memory-setup --platform=cursor # specific platform +npx agentkits-memory-setup --platform=all # all platforms +npx agentkits-memory-setup --force # re-install/update + # Start MCP server npx agentkits-memory-server -# Start web viewer (port 1905) +# Web viewer (port 1905) npx agentkits-memory-web -# View stored memories (terminal) +# Terminal viewer npx agentkits-memory-viewer +npx agentkits-memory-viewer --stats # database statistics +npx agentkits-memory-viewer --json # JSON output -# Save memory from CLI +# Save from CLI npx agentkits-memory-save "Use JWT with refresh tokens" --category pattern --tags auth,security -# Setup hooks for auto-capture -npx agentkits-memory-setup +# Settings +npx agentkits-memory-hook settings . # view current settings +npx agentkits-memory-hook settings . --reset # reset to defaults +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... + +# Export / Import +npx agentkits-memory-hook export . my-project ./backup.json +npx agentkits-memory-hook import . ./backup.json + +# Lifecycle management +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 +npx agentkits-memory-hook lifecycle-stats . ``` --- @@ -239,47 +280,146 @@ const entry = await memory.getByKey('patterns', 'auth-pattern'); ## Auto-Capture Hooks -The package includes hooks for automatically capturing AI coding sessions: +Hooks automatically capture your AI coding sessions (Claude Code and OpenCode only): | Hook | Trigger | Action | |------|---------|--------| -| `context` | Session Start | Injects previous session context | -| `session-init` | First User Prompt | Initializes session record | -| `observation` | After Tool Use | Captures tool usage | -| `summarize` | Session End | Generates session summary | +| `context` | Session Start | Injects previous session context + memory status | +| `session-init` | User Prompt | Initializes/resumes session, records prompts | +| `observation` | After Tool Use | Captures tool usage with intent detection | +| `summarize` | Session End | Generates structured session summary | +| `user-message` | Session Start | Displays memory status to user (stderr) | Setup hooks: ```bash npx agentkits-memory-setup ``` -Or manually copy `hooks.json` to your project: +**What gets captured automatically:** +- File reads/writes with paths +- Code changes as structured diffs (before → after) +- Developer intent (bugfix, feature, refactor, investigation, etc.) +- Session summaries with decisions, errors, and next steps +- Multi-prompt tracking within sessions + +--- + +## Multi-Platform Support + +| Platform | MCP | Hooks | Rules File | Setup | +|----------|-----|-------|------------|-------| +| **Claude Code** | `.claude/settings.json` | ✅ Full | CLAUDE.md (skill) | `--platform=claude-code` | +| **Cursor** | `.cursor/mcp.json` | — | `.cursorrules` | `--platform=cursor` | +| **Windsurf** | `.windsurf/mcp.json` | — | `.windsurfrules` | `--platform=windsurf` | +| **Cline** | `.mcp.json` | — | `.clinerules` | `--platform=cline` | +| **OpenCode** | `.mcp.json` | ✅ Full | — | `--platform=opencode` | + +- **MCP Server** works with all platforms (memory tools via MCP protocol) +- **Hooks** provide auto-capture on Claude Code and OpenCode +- **Rules files** teach Cursor/Windsurf/Cline the memory workflow +- **Memory data** always stored in `.claude/memory/` (single source of truth) + +--- + +## Background Workers + +After each session, background workers process queued tasks: + +| Worker | Task | Description | +|--------|------|-------------| +| `embed-session` | Embeddings | Generate vector embeddings for semantic search | +| `enrich-session` | AI Enrichment | Enrich observations with AI-generated summaries, facts, concepts | +| `compress-session` | Compression | Compress old observations (10:1–25:1) and generate session digests (20:1–100:1) | + +Workers run automatically after session end. Each worker: +- Processes up to 200 items per run +- Uses lock files to prevent concurrent execution +- Auto-terminates after 5 minutes (prevents zombies) +- Retries failed tasks up to 3 times + +--- + +## AI Provider Configuration + +AI enrichment supports multiple providers: + +| Provider | Config Key | Notes | +|----------|-----------|-------| +| **Claude CLI** (default) | `claude-cli` | Uses `claude --print`, no API key needed | +| **OpenAI** | `openai` | Requires `apiKey` | +| **OpenRouter** | `openrouter` | Requires `apiKey` | +| **Google Gemini** | `gemini` | Requires `apiKey` | +| **GLM (Zhipu)** | `glm` | Requires `apiKey` | + +Configure via CLI: +```bash +npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... +npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +``` + +--- + +## Lifecycle Management + +Manage memory growth over time: + ```bash -cp node_modules/@aitytech/agentkits-memory/hooks.json .claude/hooks.json +# Compress observations older than 7 days, archive sessions older than 30 days +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 + +# Also auto-delete archived sessions older than 90 days +npx agentkits-memory-hook lifecycle . --compress-days=7 --archive-days=30 --delete --delete-days=90 + +# View lifecycle statistics +npx agentkits-memory-hook lifecycle-stats . +``` + +| Stage | What Happens | +|-------|-------------| +| **Compress** | AI-compresses observations, generates session digests | +| **Archive** | Marks old sessions as archived (excluded from context) | +| **Delete** | Removes archived sessions (opt-in, requires `--delete`) | + +--- + +## Export / Import + +Backup and restore your project memories: + +```bash +# Export all sessions for a project +npx agentkits-memory-hook export . my-project ./backup.json + +# Import from backup (deduplicates automatically) +npx agentkits-memory-hook import . ./backup.json ``` +Export format includes sessions, observations, prompts, and summaries. + --- ## Memory Categories | Category | Use Case | |----------|----------| -| `decision` | Architecture decisions, ADRs | -| `pattern` | Reusable code patterns | -| `error` | Error solutions and fixes | -| `context` | Project context and facts | -| `observation` | Session observations | +| `decision` | Architecture decisions, tech stack picks, trade-offs | +| `pattern` | Coding conventions, project patterns, recurring approaches | +| `error` | Bug fixes, error solutions, debugging insights | +| `context` | Project background, team conventions, environment setup | +| `observation` | Auto-captured session observations | --- ## Storage -Memories are stored in `.claude/memory/memory.db` within your project directory. +Memories are stored in `.claude/memory/` within your project directory. ``` .claude/memory/ -├── memory.db # SQLite database -└── memory.db-wal # Write-ahead log (temp) +├── memory.db # SQLite database (all data) +├── memory.db-wal # Write-ahead log (temp) +├── settings.json # Persistent settings (AI provider, context config) +└── embeddings-cache/ # Cached vector embeddings ``` --- diff --git a/skills/recall/SKILL.md b/skills/memory/SKILL.md similarity index 98% rename from skills/recall/SKILL.md rename to skills/memory/SKILL.md index 0239d99..81bf283 100644 --- a/skills/recall/SKILL.md +++ b/skills/memory/SKILL.md @@ -1,9 +1,9 @@ --- -name: recall +name: memory description: Use when you need to recall past work, previous decisions, error solutions, or project history. Activates the 3-layer memory search workflow for token-efficient retrieval. --- -# Memory Recall Skill +# AgentKits Memory Skill ## When to Activate diff --git a/src/cli/__tests__/platforms.test.ts b/src/cli/__tests__/platforms.test.ts new file mode 100644 index 0000000..429512d --- /dev/null +++ b/src/cli/__tests__/platforms.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for Platform Registry + * + * @module @agentkits/memory/cli/__tests__/platforms.test + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + PLATFORMS, + ALL_PLATFORM_IDS, + detectPlatforms, + resolvePlatforms, + type PlatformId, +} from '../platforms.js'; + +describe('Platform Registry', () => { + // ===== PLATFORMS constant ===== + + describe('PLATFORMS', () => { + it('should define all 5 platforms', () => { + expect(ALL_PLATFORM_IDS).toHaveLength(5); + expect(ALL_PLATFORM_IDS).toContain('claude-code'); + expect(ALL_PLATFORM_IDS).toContain('cursor'); + expect(ALL_PLATFORM_IDS).toContain('windsurf'); + expect(ALL_PLATFORM_IDS).toContain('cline'); + expect(ALL_PLATFORM_IDS).toContain('opencode'); + }); + + it('should have valid configDir for each platform', () => { + for (const platform of Object.values(PLATFORMS)) { + expect(platform.configDir).toBeTruthy(); + expect(platform.configDir.startsWith('.')).toBe(true); + } + }); + + it('should have valid mcpConfigPath for each platform', () => { + for (const platform of Object.values(PLATFORMS)) { + expect(platform.mcpConfigPath).toBeTruthy(); + expect(platform.mcpConfigPath).toMatch(/\.(json)$/); + } + }); + + it('should mark claude-code and opencode as supporting hooks', () => { + expect(PLATFORMS['claude-code'].supportsHooks).toBe(true); + expect(PLATFORMS.opencode.supportsHooks).toBe(true); + }); + + it('should mark cursor, windsurf, cline as not supporting hooks', () => { + expect(PLATFORMS.cursor.supportsHooks).toBe(false); + expect(PLATFORMS.windsurf.supportsHooks).toBe(false); + expect(PLATFORMS.cline.supportsHooks).toBe(false); + }); + + it('should have rules files for cursor, windsurf, cline', () => { + expect(PLATFORMS.cursor.rulesFile).toBe('.cursorrules'); + expect(PLATFORMS.windsurf.rulesFile).toBe('.windsurfrules'); + expect(PLATFORMS.cline.rulesFile).toBe('.clinerules'); + }); + + it('should not have rules files for claude-code and opencode', () => { + expect(PLATFORMS['claude-code'].rulesFile).toBeNull(); + expect(PLATFORMS.opencode.rulesFile).toBeNull(); + }); + + it('should only have skillsDir for claude-code', () => { + expect(PLATFORMS['claude-code'].skillsDir).toBe('.claude/skills'); + expect(PLATFORMS.cursor.skillsDir).toBeNull(); + expect(PLATFORMS.windsurf.skillsDir).toBeNull(); + expect(PLATFORMS.cline.skillsDir).toBeNull(); + expect(PLATFORMS.opencode.skillsDir).toBeNull(); + }); + + it('should use embedded mcpConfigFormat only for claude-code', () => { + expect(PLATFORMS['claude-code'].mcpConfigFormat).toBe('embedded'); + expect(PLATFORMS.cursor.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.windsurf.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.cline.mcpConfigFormat).toBe('standalone'); + expect(PLATFORMS.opencode.mcpConfigFormat).toBe('standalone'); + }); + }); + + // ===== detectPlatforms ===== + + describe('detectPlatforms', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'platforms-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty array for empty directory', () => { + expect(detectPlatforms(tmpDir)).toEqual([]); + }); + + it('should detect claude-code from .claude directory', () => { + fs.mkdirSync(path.join(tmpDir, '.claude')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('claude-code'); + }); + + it('should detect cursor from .cursor directory', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('cursor'); + }); + + it('should detect multiple platforms', () => { + fs.mkdirSync(path.join(tmpDir, '.claude')); + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.windsurf')); + const detected = detectPlatforms(tmpDir); + expect(detected).toContain('claude-code'); + expect(detected).toContain('cursor'); + expect(detected).toContain('windsurf'); + expect(detected).toHaveLength(3); + }); + + it('should detect all 5 platforms when all present', () => { + for (const platform of Object.values(PLATFORMS)) { + fs.mkdirSync(path.join(tmpDir, platform.configDir)); + } + const detected = detectPlatforms(tmpDir); + expect(detected).toHaveLength(5); + }); + }); + + // ===== resolvePlatforms ===== + + describe('resolvePlatforms', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'platforms-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return all platforms for "all"', () => { + const result = resolvePlatforms('all', tmpDir); + expect(result).toEqual(ALL_PLATFORM_IDS); + }); + + it('should return single platform for "cursor"', () => { + const result = resolvePlatforms('cursor', tmpDir); + expect(result).toEqual(['cursor']); + }); + + it('should return multiple platforms for comma-separated list', () => { + const result = resolvePlatforms('cursor,windsurf', tmpDir); + expect(result).toEqual(['cursor', 'windsurf']); + }); + + it('should filter invalid platform names', () => { + const result = resolvePlatforms('cursor,invalid,windsurf', tmpDir); + expect(result).toEqual(['cursor', 'windsurf']); + }); + + it('should fall back to auto-detect when all names are invalid', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const result = resolvePlatforms('invalid', tmpDir); + expect(result).toContain('cursor'); + }); + + it('should auto-detect when no platformArg is given', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.claude')); + const result = resolvePlatforms(undefined, tmpDir); + expect(result).toContain('claude-code'); + expect(result).toContain('cursor'); + }); + + it('should default to claude-code when nothing detected', () => { + const result = resolvePlatforms(undefined, tmpDir); + expect(result).toEqual(['claude-code']); + }); + + it('should handle empty string by auto-detecting', () => { + const result = resolvePlatforms('', tmpDir); + // Empty string is falsy so falls through to auto-detect + expect(result).toEqual(['claude-code']); + }); + }); +}); diff --git a/src/cli/__tests__/rules-generator.test.ts b/src/cli/__tests__/rules-generator.test.ts new file mode 100644 index 0000000..3112d7b --- /dev/null +++ b/src/cli/__tests__/rules-generator.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for Rules File Generator + * + * @module @agentkits/memory/cli/__tests__/rules-generator.test + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { generateRulesContent, installRulesFile } from '../rules-generator.js'; + +describe('Rules Generator', () => { + // ===== generateRulesContent ===== + + describe('generateRulesContent', () => { + it('should include platform name', () => { + const content = generateRulesContent('Cursor'); + expect(content).toContain('Cursor'); + }); + + it('should include all MCP tool names', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('memory_status'); + expect(content).toContain('memory_save'); + expect(content).toContain('memory_search'); + expect(content).toContain('memory_timeline'); + expect(content).toContain('memory_details'); + expect(content).toContain('memory_recall'); + expect(content).toContain('memory_list'); + expect(content).toContain('memory_update'); + expect(content).toContain('memory_delete'); + }); + + it('should include workflow steps', () => { + const content = generateRulesContent('Test'); + // Workflow step numbers 0-4 + expect(content).toContain('memory_status()'); + expect(content).toContain('memory_save('); + expect(content).toContain('memory_search('); + expect(content).toContain('memory_timeline('); + expect(content).toContain('memory_details('); + }); + + it('should include category table', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('decision'); + expect(content).toContain('pattern'); + expect(content).toContain('error'); + expect(content).toContain('context'); + expect(content).toContain('observation'); + }); + + it('should include start/end markers', () => { + const content = generateRulesContent('Test'); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('should include token efficiency rules', () => { + const content = generateRulesContent('Test'); + expect(content).toContain('87%'); + }); + }); + + // ===== installRulesFile ===== + + describe('installRulesFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rules-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should create new rules file when not exists', () => { + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(true); + expect(result.action).toBe('created'); + expect(fs.existsSync(result.path)).toBe(true); + + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('AgentKits Memory'); + expect(content).toContain('Cursor'); + }); + + it('should append to existing rules file without marker', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '# Existing rules\nSome content\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(true); + expect(result.action).toBe('updated'); + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('# Existing rules'); + expect(content).toContain('AgentKits Memory'); + }); + + it('should skip existing file with marker when not forced', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '\nold content\n\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', false); + expect(result.installed).toBe(false); + expect(result.action).toBe('skipped'); + }); + + it('should replace existing marker section when forced', () => { + const filePath = path.join(tmpDir, '.cursorrules'); + fs.writeFileSync(filePath, '# Header\n\nold content\n\n# Footer\n'); + + const result = installRulesFile(tmpDir, '.cursorrules', true); + expect(result.installed).toBe(true); + expect(result.action).toBe('updated'); + + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('# Header'); + expect(content).toContain('# Footer'); + expect(content).not.toContain('old content'); + expect(content).toContain('memory_save'); + }); + + it('should generate correct platform name from filename', () => { + const result = installRulesFile(tmpDir, '.windsurfrules', false); + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('Windsurf'); + }); + + it('should generate correct platform name for clinerules', () => { + const result = installRulesFile(tmpDir, '.clinerules', false); + const content = fs.readFileSync(result.path, 'utf-8'); + expect(content).toContain('Cline'); + }); + }); +}); diff --git a/src/cli/platforms.ts b/src/cli/platforms.ts new file mode 100644 index 0000000..f96f927 --- /dev/null +++ b/src/cli/platforms.ts @@ -0,0 +1,149 @@ +/** + * Platform definitions for AI coding assistants. + * + * Centralized registry of supported platforms with their + * config paths, MCP locations, rules files, and capabilities. + * + * @module @agentkits/memory/cli/platforms + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ===== Types ===== + +export type PlatformId = 'claude-code' | 'cursor' | 'windsurf' | 'cline' | 'opencode'; + +export interface PlatformDefinition { + /** Unique platform identifier */ + id: PlatformId; + /** Human-readable name */ + name: string; + /** Config directory relative to project root */ + configDir: string; + /** MCP config file path relative to project root */ + mcpConfigPath: string; + /** + * How MCP server is stored in the config file: + * - 'embedded': mcpServers key inside an existing settings file (Claude Code) + * - 'standalone': dedicated mcp.json file with { mcpServers: { ... } } + */ + mcpConfigFormat: 'embedded' | 'standalone'; + /** Rules file name relative to project root (null if not supported) */ + rulesFile: string | null; + /** Skills directory relative to project root (null if not supported) */ + skillsDir: string | null; + /** Whether hooks are supported natively */ + supportsHooks: boolean; +} + +// ===== Platform Registry ===== + +export const PLATFORMS: Record = { + 'claude-code': { + id: 'claude-code', + name: 'Claude Code', + configDir: '.claude', + mcpConfigPath: '.claude/settings.json', + mcpConfigFormat: 'embedded', + rulesFile: null, // Claude Code uses CLAUDE.md (managed separately) + skillsDir: '.claude/skills', + supportsHooks: true, + }, + cursor: { + id: 'cursor', + name: 'Cursor', + configDir: '.cursor', + mcpConfigPath: '.cursor/mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.cursorrules', + skillsDir: null, + supportsHooks: false, + }, + windsurf: { + id: 'windsurf', + name: 'Windsurf', + configDir: '.windsurf', + mcpConfigPath: '.windsurf/mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.windsurfrules', + skillsDir: null, + supportsHooks: false, + }, + cline: { + id: 'cline', + name: 'Cline', + configDir: '.cline', + mcpConfigPath: '.mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: '.clinerules', + skillsDir: null, + supportsHooks: false, + }, + opencode: { + id: 'opencode', + name: 'OpenCode', + configDir: '.opencode', + mcpConfigPath: '.mcp.json', + mcpConfigFormat: 'standalone', + rulesFile: null, + skillsDir: null, + supportsHooks: true, + }, +}; + +/** All platform IDs */ +export const ALL_PLATFORM_IDS: PlatformId[] = Object.keys(PLATFORMS) as PlatformId[]; + +// ===== Detection & Resolution ===== + +/** + * Detect which platforms are present in a project directory. + * Checks for existence of platform-specific config directories. + */ +export function detectPlatforms(projectDir: string): PlatformId[] { + const detected: PlatformId[] = []; + + for (const platform of Object.values(PLATFORMS)) { + const configPath = path.join(projectDir, platform.configDir); + if (fs.existsSync(configPath)) { + detected.push(platform.id); + } + } + + return detected; +} + +/** + * Resolve platforms from CLI --platform flag. + * + * Supports: + * - 'all' → all platforms + * - 'cursor,windsurf' → specific platforms + * - 'cursor' → single platform + * - undefined → auto-detect, fallback to ['claude-code'] + */ +export function resolvePlatforms( + platformArg: string | undefined, + projectDir: string +): PlatformId[] { + // Explicit 'all' + if (platformArg === 'all') { + return ALL_PLATFORM_IDS; + } + + // Explicit platform(s) + if (platformArg) { + const ids = platformArg.split(',').map(s => s.trim()) as PlatformId[]; + const valid = ids.filter(id => id in PLATFORMS); + if (valid.length > 0) return valid; + // Invalid platform names → fall through to auto-detect + } + + // Auto-detect + const detected = detectPlatforms(projectDir); + if (detected.length > 0) return detected; + + // Default: Claude Code + return ['claude-code']; +} diff --git a/src/cli/rules-generator.ts b/src/cli/rules-generator.ts new file mode 100644 index 0000000..a2c4fd5 --- /dev/null +++ b/src/cli/rules-generator.ts @@ -0,0 +1,126 @@ +/** + * Rules file generator for non-Claude platforms. + * + * Generates platform-specific rules files (.cursorrules, .windsurfrules, .clinerules) + * with MCP memory workflow instructions so AI assistants use memory tools proactively. + * + * @module @agentkits/memory/cli/rules-generator + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const MARKER_START = ''; +const MARKER_END = ''; + +/** + * Generate rules file content for AI coding assistants. + * Instructs the AI to use MCP memory tools proactively. + */ +export function generateRulesContent(platformName: string): string { + return `${MARKER_START} +# AgentKits Memory — ${platformName} + +This project uses AgentKits Memory for persistent project context across sessions. +The following MCP tools are available via the "memory" server. + +## Memory Workflow (ALWAYS FOLLOW) + +0. \`memory_status()\` — Check if memories exist BEFORE searching +1. \`memory_save(content, category, tags)\` — Save decisions, patterns, errors, context +2. \`memory_search(query)\` — Get index with IDs (~50 tokens/result) +3. \`memory_timeline(anchor="ID")\` — Get context around interesting results +4. \`memory_details(ids=["ID1","ID2"])\` — Fetch full content ONLY for filtered IDs + +**IMPORTANT:** Do NOT call memory_search/timeline/details on empty memory — save first. + +## Also Available + +- \`memory_recall(topic)\` — Quick topic overview +- \`memory_list()\` — List recent memories +- \`memory_update(id, content)\` — Update existing memory +- \`memory_delete(ids)\` — Remove outdated memories + +## When to Save Memories + +Save important context proactively using \`memory_save(content, category, tags, importance)\`: + +| Category | What to Save | +|----------|-------------| +| **decision** | Architectural choices, tech stack picks, trade-offs | +| **pattern** | Coding conventions, project patterns, recurring approaches | +| **error** | Bug fixes, error solutions, debugging insights | +| **context** | Project background, team conventions, environment setup | +| **observation** | What you learned during implementation | + +## Token Efficiency Rules + +1. ALWAYS start with \`memory_search\` (Layer 1), never jump to \`memory_details\` +2. Review search results and select only relevant IDs before fetching details +3. Use filters (category, date range) to narrow results +4. Limit \`memory_details\` to 3-5 IDs per call +5. This workflow saves ~87% tokens vs fetching everything at once + +## At Session Start + +1. Call \`memory_status()\` to check if memories exist +2. If memories exist, call \`memory_recall(topic)\` for relevant project context +3. Save important decisions and patterns as you work +${MARKER_END}`; +} + +/** + * Install rules file to project root. + * If the file exists, appends/replaces the AgentKits section. + * If the file doesn't exist, creates it. + */ +export function installRulesFile( + projectDir: string, + rulesFileName: string, + force: boolean, + asJson: boolean = false, +): { installed: boolean; path: string; action: 'created' | 'updated' | 'skipped' } { + const filePath = path.join(projectDir, rulesFileName); + const platformName = rulesFileName + .replace(/^\./, '') + .replace(/rules$/, '') + .replace(/^./, c => c.toUpperCase()); + + const newContent = generateRulesContent(platformName); + + // File doesn't exist → create + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, newContent + '\n'); + return { installed: true, path: filePath, action: 'created' }; + } + + // File exists — check for existing AgentKits section + const existing = fs.readFileSync(filePath, 'utf-8'); + const hasMarker = existing.includes(MARKER_START); + + if (hasMarker) { + if (!force) { + return { installed: false, path: filePath, action: 'skipped' }; + } + // Replace existing section + const regex = new RegExp( + escapeRegex(MARKER_START) + '[\\s\\S]*?' + escapeRegex(MARKER_END), + 'g' + ); + const updated = existing.replace(regex, newContent); + fs.writeFileSync(filePath, updated); + return { installed: true, path: filePath, action: 'updated' }; + } + + // File exists but no marker → append + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + fs.writeFileSync(filePath, existing + separator + newContent + '\n'); + if (!asJson) { + // Inform user we appended to existing file + } + return { installed: true, path: filePath, action: 'updated' }; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 7fc23c2..a8bb641 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -3,13 +3,15 @@ * AgentKits Memory Setup CLI * * Sets up memory hooks, MCP server, and downloads embedding model. - * Supports multiple AI tools: Claude Code, Cursor, Windsurf, etc. + * Supports multiple AI tools: Claude Code, Cursor, Windsurf, Cline, OpenCode. * * Usage: * npx agentkits-memory-setup [options] * * Options: * --project-dir=X Project directory (default: cwd) + * --platform=X Target platform(s): claude-code, cursor, windsurf, cline, opencode, all + * Default: auto-detect, fallback to claude-code * --force Overwrite existing configuration * --skip-model Skip embedding model download * --skip-mcp Skip MCP server configuration @@ -22,6 +24,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { LocalEmbeddingsService } from '../embeddings/local-embeddings.js'; +import { type PlatformDefinition, type PlatformId, PLATFORMS, resolvePlatforms } from './platforms.js'; +import { installRulesFile } from './rules-generator.js'; const args = process.argv.slice(2); @@ -236,85 +240,79 @@ function mergeHooks( } /** - * Configure MCP server for different AI tools - * Creates/updates config files for: Claude Code, Cursor, Windsurf, etc. + * Configure MCP server for a specific platform. + * Handles two formats: + * - 'embedded': mcpServers key inside an existing settings file (Claude Code) + * - 'standalone': dedicated mcp.json file with { mcpServers: { ... } } */ -function configureMcp( +function configureMcpForPlatform( projectDir: string, - claudeSettings: ClaudeSettings, + platform: PlatformDefinition, force: boolean, - asJson: boolean -): { configured: string[]; skipped: string[] } { - const configured: string[] = []; - const skipped: string[] = []; - - // 1. Add to Claude Code settings.json (mcpServers key) - // Always merge with existing servers, never overwrite - if (!claudeSettings.mcpServers) { - claudeSettings.mcpServers = {}; - } - - if (!claudeSettings.mcpServers.memory || force) { - claudeSettings.mcpServers.memory = MEMORY_MCP_SERVER; - configured.push('Claude Code (.claude/settings.json)'); - } else { - skipped.push('Claude Code (already configured)'); + asJson: boolean, + claudeSettings?: ClaudeSettings, +): { configured: boolean; path: string } { + const mcpPath = path.join(projectDir, platform.mcpConfigPath); + + if (platform.mcpConfigFormat === 'embedded' && claudeSettings) { + // Claude Code: mcpServers key inside settings.json + if (!claudeSettings.mcpServers) { + claudeSettings.mcpServers = {}; + } + if (!claudeSettings.mcpServers.memory || force) { + claudeSettings.mcpServers.memory = MEMORY_MCP_SERVER; + return { configured: true, path: mcpPath }; + } + return { configured: false, path: mcpPath }; } - // 2. Create/update root .mcp.json for other tools (Cursor, Windsurf, Claude Code, etc.) - // Always merge with existing servers, never overwrite - const mcpJsonPath = path.join(projectDir, '.mcp.json'); + // Standalone mcp.json try { let existing: McpConfig = { mcpServers: {} }; - // Load existing config if present - if (fs.existsSync(mcpJsonPath)) { + if (fs.existsSync(mcpPath)) { try { - existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as McpConfig; + existing = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')) as McpConfig; existing.mcpServers = existing.mcpServers || {}; } catch { - // If parse fails, start fresh but warn if (!asJson) { - console.warn(' ⚠ .mcp.json parse error, creating new config'); + console.warn(` ⚠ ${platform.mcpConfigPath} parse error, creating new config`); } existing = { mcpServers: {} }; } } - // Add or update memory server if (!existing.mcpServers.memory || force) { + // Ensure parent directory exists + const mcpDir = path.dirname(mcpPath); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true }); + } existing.mcpServers.memory = MEMORY_MCP_SERVER; - fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2)); - configured.push('Universal (.mcp.json)'); - } else { - skipped.push('.mcp.json (already configured)'); - } - } catch (error) { - skipped.push(`.mcp.json (error: ${error instanceof Error ? error.message : 'unknown'})`); - } - - if (!asJson && configured.length > 0) { - console.log('\n🔌 MCP Server configured for:'); - for (const tool of configured) { - console.log(` ✓ ${tool}`); + fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2)); + return { configured: true, path: mcpPath }; } + return { configured: false, path: mcpPath }; + } catch { + return { configured: false, path: mcpPath }; } - - return { configured, skipped }; } /** - * Install memory skills to .claude/skills/ - * Copies SKILL.md files from package to project's .claude/skills/ directory + * Install memory skills to a platform's skills directory. + * Copies SKILL.md files from package to project's skills directory. */ function installSkills( projectDir: string, + platform: PlatformDefinition, force: boolean, asJson: boolean ): { installed: string[]; skipped: string[] } { const installed: string[] = []; const skipped: string[] = []; + if (!platform.skillsDir) return { installed, skipped }; + // Resolve package root: setup.ts is at dist/cli/setup.js → package root is ../../ const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..'); const sourceSkillsDir = path.join(packageRoot, 'skills'); @@ -333,7 +331,7 @@ function installSkills( for (const skillDir of skillDirs) { const sourcePath = path.join(sourceSkillsDir, skillDir.name, 'SKILL.md'); - const targetDir = path.join(projectDir, '.claude', 'skills', skillDir.name); + const targetDir = path.join(projectDir, platform.skillsDir, skillDir.name); const targetPath = path.join(targetDir, 'SKILL.md'); if (!fs.existsSync(sourcePath)) continue; @@ -353,7 +351,7 @@ function installSkills( if (!asJson && installed.length > 0) { console.log('\n🎯 Skills installed:'); for (const skill of installed) { - console.log(` ✓ ${skill} (.claude/skills/${skill}/SKILL.md)`); + console.log(` ✓ ${skill} (${platform.skillsDir}/${skill}/SKILL.md)`); } } @@ -450,6 +448,7 @@ async function main() { const skipModel = !!options['skip-model']; const skipMcp = !!options['skip-mcp']; const showHooks = !!options['show-hooks']; + const platformArg = options.platform as string | undefined; // Just show hooks config and exit if (showHooks) { @@ -457,16 +456,22 @@ async function main() { return; } + // Resolve target platforms + const targetPlatforms = resolvePlatforms(platformArg, projectDir); + + // Memory data always stored under .claude/memory (single source of truth) const claudeDir = path.join(projectDir, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); const memoryDir = path.join(claudeDir, 'memory'); try { if (!asJson) { + const platformNames = targetPlatforms.map(id => PLATFORMS[id].name).join(', '); console.log('\n🧠 AgentKits Memory Setup\n'); + console.log(` Platforms: ${platformNames}`); } - // Create directories + // Always create memory directory (single source of truth) if (!fs.existsSync(claudeDir)) { fs.mkdirSync(claudeDir, { recursive: true }); } @@ -474,28 +479,66 @@ async function main() { fs.mkdirSync(memoryDir, { recursive: true }); } - // Load or create settings - let settings: ClaudeSettings = {}; + // Track results across all platforms + const mcpConfigured: string[] = []; + const mcpSkipped: string[] = []; + const rulesInstalled: string[] = []; + const rulesSkipped: string[] = []; + let hooksResult: MergeResult = { merged: {}, added: [], skipped: [], manualRequired: [] }; + let skillsResult = { installed: [] as string[], skipped: [] as string[] }; + + // Load Claude settings (needed for embedded MCP + hooks) + let claudeSettings: ClaudeSettings = {}; if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf-8'); - settings = JSON.parse(content); + claudeSettings = JSON.parse(content); } - // Merge hooks - const hooksResult = mergeHooks(settings.hooks, MEMORY_HOOKS, force); - settings.hooks = hooksResult.merged; + // Process each platform + for (const platformId of targetPlatforms) { + const platform = PLATFORMS[platformId]; - // Configure MCP server - let mcpResult = { configured: [] as string[], skipped: [] as string[] }; - if (!skipMcp) { - mcpResult = configureMcp(projectDir, settings, force, asJson); - } + // 1. Configure MCP + if (!skipMcp) { + const mcpResult = configureMcpForPlatform( + projectDir, platform, force, asJson, + platformId === 'claude-code' ? claudeSettings : undefined, + ); + if (mcpResult.configured) { + mcpConfigured.push(`${platform.name} (${platform.mcpConfigPath})`); + } else { + mcpSkipped.push(`${platform.name} (already configured)`); + } + } + + // 2. Install hooks (Claude Code only for now; OpenCode in Phase B) + if (platformId === 'claude-code') { + hooksResult = mergeHooks(claudeSettings.hooks, MEMORY_HOOKS, force); + claudeSettings.hooks = hooksResult.merged; + } + + // 3. Install skills (platforms that support them) + if (platform.skillsDir) { + const result = installSkills(projectDir, platform, force, asJson); + skillsResult.installed.push(...result.installed); + skillsResult.skipped.push(...result.skipped); + } - // Write settings - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + // 4. Install rules file (platforms that support them) + if (platform.rulesFile) { + const result = installRulesFile(projectDir, platform.rulesFile, force, asJson); + if (result.installed) { + rulesInstalled.push(`${platform.rulesFile} (${result.action})`); + } else { + rulesSkipped.push(`${platform.rulesFile} (already configured)`); + } + } + } - // Install skills - const skillsResult = installSkills(projectDir, force, asJson); + // Write Claude settings (hooks + embedded MCP) + if (targetPlatforms.includes('claude-code')) { + fs.writeFileSync(settingsPath, JSON.stringify(claudeSettings, null, 2)); + } // Create default memory settings const settingsCreated = createDefaultSettings(memoryDir, force); @@ -511,13 +554,15 @@ async function main() { const result = { success: true, + platforms: targetPlatforms, settingsPath, memoryDir, hooksAdded: hooksResult.added, hooksSkipped: hooksResult.skipped, hooksManualRequired: hooksResult.manualRequired, skillsInstalled: skillsResult.installed, - mcpConfigured: mcpResult.configured, + mcpConfigured, + rulesInstalled, modelDownloaded, message: 'Memory setup complete', }; @@ -525,11 +570,18 @@ async function main() { if (asJson) { console.log(JSON.stringify(result, null, 2)); } else { - console.log('✅ Setup Complete\n'); - console.log(`📁 Settings: ${settingsPath}`); + console.log('\n✅ Setup Complete\n'); console.log(`📁 Memory: ${memoryDir}`); - // Show hooks status + // Show MCP status + if (mcpConfigured.length > 0) { + console.log('\n🔌 MCP Server configured for:'); + for (const entry of mcpConfigured) { + console.log(` ✓ ${entry}`); + } + } + + // Show hooks status (Claude Code only) if (hooksResult.added.length > 0) { console.log(`\n📋 Hooks added: ${hooksResult.added.join(', ')}`); } @@ -542,6 +594,14 @@ async function main() { console.log(`\n🎯 Skills: ${skillsResult.installed.join(', ')}`); } + // Show rules files status + if (rulesInstalled.length > 0) { + console.log('\n📝 Rules files:'); + for (const entry of rulesInstalled) { + console.log(` ✓ ${entry}`); + } + } + // Show manual action required if (hooksResult.manualRequired.length > 0) { console.log('\n⚠️ Manual review recommended:'); @@ -562,32 +622,34 @@ async function main() { console.log('📋 Show hooks config: npx agentkits-memory-setup --show-hooks\n'); // Show manual hook instructions if some hooks couldn't be added - const allHookEvents = Object.keys(MEMORY_HOOKS); - const addedEvents = hooksResult.added.map((h) => h.replace(/ \(.*\)$/, '')); - const missingEvents = allHookEvents.filter( - (e) => !addedEvents.includes(e) && !hooksResult.skipped.some((s) => s.startsWith(e)) - ); + if (targetPlatforms.includes('claude-code')) { + const allHookEvents = Object.keys(MEMORY_HOOKS); + const addedEvents = hooksResult.added.map((h) => h.replace(/ \(.*\)$/, '')); + const missingEvents = allHookEvents.filter( + (e) => !addedEvents.includes(e) && !hooksResult.skipped.some((s) => s.startsWith(e)) + ); - if (missingEvents.length > 0) { - console.log('━'.repeat(60)); - console.log('📝 MANUAL SETUP REQUIRED\n'); - console.log(`Some hooks could not be auto-configured.`); - console.log(`Missing: ${missingEvents.join(', ')}\n`); - console.log(`To add manually:`); - console.log(`1. Open: ${settingsPath}`); - console.log(`2. Add/merge the following into the "hooks" section:\n`); - - // Generate copy-paste JSON for missing hooks only - const missingHooksJson: Record = {}; - for (const event of missingEvents) { - const hookConfig = MEMORY_HOOKS[event]; - if (hookConfig) { - missingHooksJson[event] = hookConfig; + if (missingEvents.length > 0) { + console.log('━'.repeat(60)); + console.log('📝 MANUAL SETUP REQUIRED\n'); + console.log(`Some hooks could not be auto-configured.`); + console.log(`Missing: ${missingEvents.join(', ')}\n`); + console.log(`To add manually:`); + console.log(`1. Open: ${settingsPath}`); + console.log(`2. Add/merge the following into the "hooks" section:\n`); + + // Generate copy-paste JSON for missing hooks only + const missingHooksJson: Record = {}; + for (const event of missingEvents) { + const hookConfig = MEMORY_HOOKS[event]; + if (hookConfig) { + missingHooksJson[event] = hookConfig; + } } - } - console.log(JSON.stringify(missingHooksJson, null, 2)); - console.log('\n━'.repeat(60)); + console.log(JSON.stringify(missingHooksJson, null, 2)); + console.log('\n━'.repeat(60)); + } } } } catch (error) { diff --git a/src/hooks/__tests__/adapters.test.ts b/src/hooks/__tests__/adapters.test.ts new file mode 100644 index 0000000..3b1fe62 --- /dev/null +++ b/src/hooks/__tests__/adapters.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for Hook Platform Adapters + * + * @module @agentkits/memory/hooks/__tests__/adapters.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ClaudeCodeAdapter } from '../adapters/claude-code-adapter.js'; +import { OpenCodeAdapter } from '../adapters/opencode-adapter.js'; +import { GenericAdapter } from '../adapters/generic-adapter.js'; +import { resolveAdapter } from '../adapters/platform-adapter.js'; +import type { HookResult } from '../types.js'; + +describe('Platform Adapters', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + // ===== resolveAdapter ===== + + describe('resolveAdapter', () => { + beforeEach(() => { + delete process.env.AGENTKITS_PLATFORM; + }); + + it('should default to claude-code when no env var', () => { + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + + it('should resolve opencode from env var', () => { + process.env.AGENTKITS_PLATFORM = 'opencode'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('opencode'); + }); + + it('should resolve generic from env var', () => { + process.env.AGENTKITS_PLATFORM = 'generic'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('generic'); + }); + + it('should resolve claude-code from env var', () => { + process.env.AGENTKITS_PLATFORM = 'claude-code'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + + it('should fall back to claude-code for unknown platform', () => { + process.env.AGENTKITS_PLATFORM = 'unknown-platform'; + const adapter = resolveAdapter(); + expect(adapter.name).toBe('claude-code'); + }); + }); + + // ===== ClaudeCodeAdapter ===== + + describe('ClaudeCodeAdapter', () => { + const adapter = new ClaudeCodeAdapter(); + + it('should have name claude-code', () => { + expect(adapter.name).toBe('claude-code'); + }); + + it('should support all 5 events', () => { + expect(adapter.supportedEvents).toContain('context'); + expect(adapter.supportedEvents).toContain('session-init'); + expect(adapter.supportedEvents).toContain('observation'); + expect(adapter.supportedEvents).toContain('summarize'); + expect(adapter.supportedEvents).toContain('user-message'); + }); + + describe('parseInput', () => { + it('should parse valid Claude Code JSON', () => { + const input = JSON.stringify({ + session_id: 'test-session', + cwd: '/test/project', + prompt: 'Hello world', + tool_name: 'Edit', + tool_input: { file_path: '/test/file.ts' }, + tool_result: 'success', + transcript_path: '/test/transcript.jsonl', + stop_reason: 'user', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('test-session'); + expect(result.cwd).toBe('/test/project'); + expect(result.project).toBe('project'); + expect(result.prompt).toBe('Hello world'); + expect(result.toolName).toBe('Edit'); + expect(result.toolInput).toEqual({ file_path: '/test/file.ts' }); + expect(result.toolResponse).toBe('success'); + expect(result.transcriptPath).toBe('/test/transcript.jsonl'); + expect(result.stopReason).toBe('user'); + expect(result.timestamp).toBeGreaterThan(0); + }); + + it('should generate session ID when missing', () => { + const result = adapter.parseInput(JSON.stringify({ cwd: '/test' })); + expect(result.sessionId).toMatch(/^session_/); + }); + + it('should use process.cwd() when cwd missing', () => { + const result = adapter.parseInput(JSON.stringify({ session_id: 'x' })); + expect(result.cwd).toBe(process.cwd()); + }); + + it('should handle invalid JSON gracefully', () => { + const result = adapter.parseInput('not json'); + expect(result.sessionId).toMatch(/^session_/); + expect(result.cwd).toBe(process.cwd()); + }); + + it('should handle empty string', () => { + const result = adapter.parseInput(''); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should format result with additionalContext as SessionStart', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'Memory context here', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart'); + expect(output.hookSpecificOutput.additionalContext).toBe('Memory context here'); + }); + + it('should format standard response without context', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.suppressOutput).toBe(true); + expect(output.hookSpecificOutput).toBeUndefined(); + }); + }); + }); + + // ===== OpenCodeAdapter ===== + + describe('OpenCodeAdapter', () => { + const adapter = new OpenCodeAdapter(); + + it('should have name opencode', () => { + expect(adapter.name).toBe('opencode'); + }); + + it('should support 4 events (no user-message)', () => { + expect(adapter.supportedEvents).toContain('context'); + expect(adapter.supportedEvents).toContain('session-init'); + expect(adapter.supportedEvents).toContain('observation'); + expect(adapter.supportedEvents).toContain('summarize'); + expect(adapter.supportedEvents).not.toContain('user-message'); + }); + + describe('parseInput', () => { + it('should parse same format as Claude Code', () => { + const input = JSON.stringify({ + session_id: 'oc-session', + cwd: '/test/oc', + prompt: 'Test prompt', + tool_name: 'Bash', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('oc-session'); + expect(result.cwd).toBe('/test/oc'); + expect(result.prompt).toBe('Test prompt'); + expect(result.toolName).toBe('Bash'); + }); + + it('should handle invalid JSON', () => { + const result = adapter.parseInput('broken'); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should include additionalContext at top level', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'context text', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBe('context text'); + // OpenCode doesn't use hookSpecificOutput + expect(output.hookSpecificOutput).toBeUndefined(); + }); + + it('should include error when present', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + error: 'Something went wrong', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.error).toBe('Something went wrong'); + }); + + it('should output minimal JSON without context or error', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBeUndefined(); + expect(output.error).toBeUndefined(); + }); + }); + }); + + // ===== GenericAdapter ===== + + describe('GenericAdapter', () => { + const adapter = new GenericAdapter(); + + it('should have name generic', () => { + expect(adapter.name).toBe('generic'); + }); + + describe('parseInput', () => { + it('should parse camelCase fields', () => { + const input = JSON.stringify({ + sessionId: 'gen-session', + cwd: '/test/gen', + prompt: 'Test', + toolName: 'Read', + toolInput: { file_path: '/x' }, + toolResponse: 'content', + transcriptPath: '/test/t.jsonl', + stopReason: 'done', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('gen-session'); + expect(result.cwd).toBe('/test/gen'); + expect(result.prompt).toBe('Test'); + expect(result.toolName).toBe('Read'); + expect(result.toolInput).toEqual({ file_path: '/x' }); + expect(result.toolResponse).toBe('content'); + expect(result.transcriptPath).toBe('/test/t.jsonl'); + expect(result.stopReason).toBe('done'); + }); + + it('should also accept snake_case fields as fallback', () => { + const input = JSON.stringify({ + session_id: 'snake-session', + cwd: '/test', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_result: 'files', + transcript_path: '/t.jsonl', + stop_reason: 'end', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('snake-session'); + expect(result.toolName).toBe('Bash'); + expect(result.toolInput).toEqual({ command: 'ls' }); + expect(result.toolResponse).toBe('files'); + expect(result.transcriptPath).toBe('/t.jsonl'); + expect(result.stopReason).toBe('end'); + }); + + it('should prefer camelCase over snake_case', () => { + const input = JSON.stringify({ + sessionId: 'camel', + session_id: 'snake', + cwd: '/test', + }); + + const result = adapter.parseInput(input); + expect(result.sessionId).toBe('camel'); + }); + + it('should accept project field', () => { + const input = JSON.stringify({ + cwd: '/test/dir', + project: 'my-custom-project', + }); + + const result = adapter.parseInput(input); + expect(result.project).toBe('my-custom-project'); + }); + + it('should derive project from cwd when not provided', () => { + const input = JSON.stringify({ cwd: '/test/my-project' }); + const result = adapter.parseInput(input); + expect(result.project).toBe('my-project'); + }); + + it('should handle invalid JSON', () => { + const result = adapter.parseInput('{}{}'); + expect(result.sessionId).toMatch(/^session_/); + }); + }); + + describe('formatOutput', () => { + it('should format with additionalContext', () => { + const result: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'some context', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.continue).toBe(true); + expect(output.additionalContext).toBe('some context'); + }); + + it('should format with error', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + error: 'fail', + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(output.error).toBe('fail'); + }); + + it('should format minimal response', () => { + const result: HookResult = { + continue: true, + suppressOutput: true, + }; + + const output = JSON.parse(adapter.formatOutput(result)); + expect(Object.keys(output)).toEqual(['continue']); + }); + }); + }); + + // ===== Cross-adapter consistency ===== + + describe('Cross-adapter consistency', () => { + const adapters = [ + new ClaudeCodeAdapter(), + new OpenCodeAdapter(), + new GenericAdapter(), + ]; + + it('all adapters should handle empty JSON', () => { + for (const adapter of adapters) { + const result = adapter.parseInput('{}'); + expect(result.sessionId).toBeTruthy(); + expect(result.cwd).toBeTruthy(); + expect(result.project).toBeTruthy(); + expect(result.timestamp).toBeGreaterThan(0); + } + }); + + it('all adapters should produce valid JSON output', () => { + const hookResult: HookResult = { + continue: true, + suppressOutput: false, + additionalContext: 'test', + }; + + for (const adapter of adapters) { + const output = adapter.formatOutput(hookResult); + expect(() => JSON.parse(output)).not.toThrow(); + } + }); + + it('all adapters should have a name', () => { + for (const adapter of adapters) { + expect(adapter.name).toBeTruthy(); + expect(typeof adapter.name).toBe('string'); + } + }); + + it('all adapters should list supported events', () => { + for (const adapter of adapters) { + expect(adapter.supportedEvents.length).toBeGreaterThan(0); + expect(adapter.supportedEvents).toContain('context'); + } + }); + }); +}); diff --git a/src/hooks/adapters/claude-code-adapter.ts b/src/hooks/adapters/claude-code-adapter.ts new file mode 100644 index 0000000..3f164dd --- /dev/null +++ b/src/hooks/adapters/claude-code-adapter.ts @@ -0,0 +1,74 @@ +/** + * Claude Code Platform Adapter + * + * Handles the stdin JSON format from Claude Code hooks. + * This is the default adapter and matches the existing behavior + * of parseHookInput() and formatResponse() in types.ts. + * + * @module @agentkits/memory/hooks/adapters/claude-code-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult, ClaudeCodeHookInput, ClaudeCodeHookResponse } from '../types.js'; +import { getProjectName, STANDARD_RESPONSE } from '../types.js'; + +/** + * Claude Code platform adapter. + * + * Input format: + * { session_id, cwd, prompt, tool_name, tool_input, tool_result, + * transcript_path, stop_reason } + * + * Output format: + * { continue, suppressOutput, hookSpecificOutput: { + * hookEventName, additionalContext } } + */ +export class ClaudeCodeAdapter implements PlatformAdapter { + readonly name = 'claude-code'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', 'user-message', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw: ClaudeCodeHookInput = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.session_id || `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.tool_name, + toolInput: raw.tool_input, + toolResponse: raw.tool_result, + transcriptPath: raw.transcript_path, + stopReason: raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + if (result.additionalContext) { + const response: ClaudeCodeHookResponse = { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: result.additionalContext, + }, + }; + return JSON.stringify(response); + } + + return JSON.stringify(STANDARD_RESPONSE); + } +} diff --git a/src/hooks/adapters/generic-adapter.ts b/src/hooks/adapters/generic-adapter.ts new file mode 100644 index 0000000..d0a05ae --- /dev/null +++ b/src/hooks/adapters/generic-adapter.ts @@ -0,0 +1,75 @@ +/** + * Generic Platform Adapter (Fallback) + * + * Accepts an already-normalized JSON format on stdin (camelCase fields). + * Useful for testing, scripting, and future platforms that adopt + * a standardized hook format. + * + * @module @agentkits/memory/hooks/adapters/generic-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { getProjectName } from '../types.js'; + +/** + * Generic platform adapter. + * + * Input format (camelCase, already normalized): + * { sessionId, cwd, prompt?, toolName?, toolInput?, + * toolResponse?, transcriptPath?, stopReason? } + * + * Output format: + * { continue, additionalContext?, error? } + */ +export class GenericAdapter implements PlatformAdapter { + readonly name = 'generic'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.sessionId || raw.session_id || `session_${Date.now()}`, + cwd, + project: raw.project || getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.toolName || raw.tool_name, + toolInput: raw.toolInput || raw.tool_input, + toolResponse: raw.toolResponse || raw.tool_result, + transcriptPath: raw.transcriptPath || raw.transcript_path, + stopReason: raw.stopReason || raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + const response: Record = { + continue: true, + }; + + if (result.additionalContext) { + response.additionalContext = result.additionalContext; + } + + if (result.error) { + response.error = result.error; + } + + return JSON.stringify(response); + } +} diff --git a/src/hooks/adapters/index.ts b/src/hooks/adapters/index.ts new file mode 100644 index 0000000..6f4ddad --- /dev/null +++ b/src/hooks/adapters/index.ts @@ -0,0 +1,11 @@ +/** + * Hook Platform Adapters + * + * @module @agentkits/memory/hooks/adapters + */ + +export type { PlatformAdapter } from './platform-adapter.js'; +export { resolveAdapter } from './platform-adapter.js'; +export { ClaudeCodeAdapter } from './claude-code-adapter.js'; +export { OpenCodeAdapter } from './opencode-adapter.js'; +export { GenericAdapter } from './generic-adapter.js'; diff --git a/src/hooks/adapters/opencode-adapter.ts b/src/hooks/adapters/opencode-adapter.ts new file mode 100644 index 0000000..d832470 --- /dev/null +++ b/src/hooks/adapters/opencode-adapter.ts @@ -0,0 +1,80 @@ +/** + * OpenCode Platform Adapter + * + * Handles the hook format for OpenCode. + * OpenCode uses a similar hook system to Claude Code. + * Initially mirrors Claude Code format; separate class allows + * future divergence as OpenCode evolves its hook API. + * + * @module @agentkits/memory/hooks/adapters/opencode-adapter + */ + +import type { PlatformAdapter } from './platform-adapter.js'; +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { getProjectName } from '../types.js'; + +/** + * OpenCode platform adapter. + * + * OpenCode hook format is currently compatible with Claude Code. + * Field mapping may diverge in future versions. + * + * Input format (same as Claude Code for now): + * { session_id, cwd, prompt, tool_name, tool_input, tool_result, + * transcript_path, stop_reason } + * + * Output format (simplified — no hookSpecificOutput): + * { continue, additionalContext? } + */ +export class OpenCodeAdapter implements PlatformAdapter { + readonly name = 'opencode'; + + readonly supportedEvents = [ + 'context', 'session-init', 'observation', 'summarize', + ] as const; + + parseInput(stdin: string): NormalizedHookInput { + try { + const raw = JSON.parse(stdin); + const cwd = raw.cwd || process.cwd(); + + return { + sessionId: raw.session_id || `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + prompt: raw.prompt, + toolName: raw.tool_name, + toolInput: raw.tool_input, + toolResponse: raw.tool_result, + transcriptPath: raw.transcript_path, + stopReason: raw.stop_reason, + timestamp: Date.now(), + }; + } catch { + const cwd = process.cwd(); + return { + sessionId: `session_${Date.now()}`, + cwd, + project: getProjectName(cwd), + timestamp: Date.now(), + }; + } + } + + formatOutput(result: HookResult): string { + // OpenCode uses a simpler output format + const response: Record = { + continue: true, + }; + + if (result.additionalContext) { + response.additionalContext = result.additionalContext; + } + + if (result.error) { + response.error = result.error; + } + + return JSON.stringify(response); + } +} diff --git a/src/hooks/adapters/platform-adapter.ts b/src/hooks/adapters/platform-adapter.ts new file mode 100644 index 0000000..e0f8ddc --- /dev/null +++ b/src/hooks/adapters/platform-adapter.ts @@ -0,0 +1,68 @@ +/** + * Platform Adapter Interface + * + * Abstracts platform-specific stdin/stdout formats for hook handlers. + * Each AI coding assistant (Claude Code, OpenCode, etc.) sends different + * JSON formats; adapters normalize them to NormalizedHookInput. + * + * @module @agentkits/memory/hooks/adapters/platform-adapter + */ + +import type { NormalizedHookInput, HookResult } from '../types.js'; +import { ClaudeCodeAdapter } from './claude-code-adapter.js'; +import { OpenCodeAdapter } from './opencode-adapter.js'; +import { GenericAdapter } from './generic-adapter.js'; + +/** + * Platform adapter interface. + * Translates between platform-specific stdin/stdout formats + * and the normalized internal types used by hook handlers. + */ +export interface PlatformAdapter { + /** Platform identifier */ + readonly name: string; + + /** + * Parse platform-specific stdin JSON into normalized input. + */ + parseInput(stdin: string): NormalizedHookInput; + + /** + * Format hook result into platform-specific stdout JSON. + */ + formatOutput(result: HookResult): string; + + /** + * Supported hook event types for this platform. + */ + readonly supportedEvents: readonly string[]; +} + +/** + * Resolve the appropriate adapter based on environment. + * + * Resolution order: + * 1. AGENTKITS_PLATFORM env var (explicit override) + * 2. Auto-detect from Claude-specific env vars + * 3. Default: claude-code (backward compatible) + */ +export function resolveAdapter(): PlatformAdapter { + const envPlatform = process.env.AGENTKITS_PLATFORM; + + if (envPlatform) { + switch (envPlatform) { + case 'opencode': + return new OpenCodeAdapter(); + case 'generic': + return new GenericAdapter(); + case 'claude-code': + return new ClaudeCodeAdapter(); + default: + // Unknown platform → fallback to Claude Code + return new ClaudeCodeAdapter(); + } + } + + // Default: Claude Code (backward compatible) + return new ClaudeCodeAdapter(); +} diff --git a/src/hooks/cli.ts b/src/hooks/cli.ts index 89e76fd..ef6e721 100644 --- a/src/hooks/cli.ts +++ b/src/hooks/cli.ts @@ -22,7 +22,8 @@ * @module @agentkits/memory/hooks/cli */ -import { parseHookInput, formatResponse, STANDARD_RESPONSE, HookResult, DEFAULT_MEMORY_SETTINGS, DEFAULT_CONTEXT_CONFIG } from './types.js'; +import { STANDARD_RESPONSE, HookResult, NormalizedHookInput, DEFAULT_MEMORY_SETTINGS, DEFAULT_CONTEXT_CONFIG } from './types.js'; +import { resolveAdapter } from './adapters/index.js'; import { createContextHook } from './context.js'; import { createSessionInitHook } from './session-init.js'; import { createObservationHook } from './observation.js'; @@ -319,12 +320,13 @@ async function main(): Promise { // Read stdin const stdin = await readStdin(); - // Parse input - const input = parseHookInput(stdin); + // Resolve platform adapter and parse input + const adapter = resolveAdapter(); + const input = adapter.parseInput(stdin); // Select and execute handler let result: HookResult | undefined; - let hook: { execute(input: ReturnType): Promise; shutdown(): Promise } | null = null; + let hook: { execute(input: NormalizedHookInput): Promise; shutdown(): Promise } | null = null; switch (event) { case 'context': @@ -360,8 +362,8 @@ async function main(): Promise { try { await hook!.shutdown(); } catch { /* ignore shutdown errors */ } } - // Output response - console.log(formatResponse(result)); + // Output response using platform adapter + console.log(adapter.formatOutput(result)); } catch (error) { // Log error to stderr (visible in verbose mode with exit 0) From 5ffb30c85d8916894d6bcb70de201d8f1ed16775 Mon Sep 17 00:00:00 2001 From: leduclinh Date: Wed, 4 Feb 2026 15:29:36 +0900 Subject: [PATCH 16/21] feat: Update web viewer to enhance session management and UI; add session embedding generation and refresh functionality --- README.md | 116 +++++++++++++++++++++++++++++++++++------- src/cli/web-viewer.ts | 94 ++++++++++++++++++---------------- 2 files changed, 149 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 35aeba8..d3c1b23 100644 --- a/README.md +++ b/README.md @@ -19,25 +19,21 @@

- Persistent Memory System for AI Coding Assistants via MCP + Persistent Memory System for AI Coding Assistants

- Fast. Local. Zero external dependencies. -

- -

- Store decisions, patterns, errors, and context that persists across sessions.
- No cloud. No API keys. No setup. Just works. + Your AI assistant forgets everything between sessions. AgentKits Memory fixes that.
+ Decisions, patterns, errors, and context — all persisted locally via MCP.

Quick Start • - Features • + How It WorksPlatformsCLIWeb Viewer • - agentkits.net + Docs

--- @@ -61,6 +57,52 @@ --- +## How It Works + +``` +Session 1: "Use JWT for auth" Session 2: "Add login endpoint" +┌──────────────────────────┐ ┌──────────────────────────┐ +│ You code with AI... │ │ AI already knows: │ +│ AI makes decisions │ │ ✓ JWT auth decision │ +│ AI encounters errors │ ───► │ ✓ Error solutions │ +│ AI learns patterns │ saved │ ✓ Code patterns │ +│ │ │ ✓ Session context │ +└──────────────────────────┘ └──────────────────────────┘ + │ ▲ + ▼ │ + .claude/memory/memory.db ──────────────────┘ + (SQLite, 100% local) +``` + +1. **Setup once** — `npx agentkits-memory-setup` configures your platform +2. **Auto-capture** — Hooks record decisions, tool usage, and summaries as you work +3. **Context injection** — Next session starts with relevant history from past sessions +4. **Background processing** — Workers enrich observations with AI, generate embeddings, compress old data +5. **Search anytime** — AI uses MCP tools (`memory_search` → `memory_details`) to find past context + +All data stays in `.claude/memory/memory.db` on your machine. No cloud. No API keys required. + +--- + +## Design Decisions That Matter + +Most memory tools scatter data across markdown files, require Python runtimes, or send your code to external APIs. AgentKits Memory makes fundamentally different choices: + +| Design Choice | Why It Matters | +|---------------|----------------| +| **Single SQLite database** | One file (`memory.db`) holds everything — memories, sessions, observations, embeddings. No scattered files to sync, no merge conflicts, no orphaned data. Backup = copy one file | +| **Native Node.js, zero Python** | Runs wherever Node runs. No conda, no pip, no virtualenv. Same language as your MCP server — one `npx` command, done | +| **Token-efficient 3-layer search** | Search index first (~50 tokens/result), then timeline context, then full details. Only fetch what you need. Other tools dump entire memory files into context, burning tokens on irrelevant content | +| **Auto-capture via hooks** | Decisions, patterns, and errors are recorded as they happen — not after you remember to save them. Session context injection happens automatically on next session start | +| **Local embeddings, no API calls** | Vector search uses a local ONNX model (multilingual-e5-small). Semantic search works offline, costs nothing, and supports 100+ languages | +| **Background workers** | AI enrichment, embedding generation, and compression run asynchronously. Your coding flow is never blocked | +| **Multi-platform from day one** | One `--platform=all` flag configures Claude Code, Cursor, Windsurf, Cline, and OpenCode simultaneously. Same memory database, different editors | +| **Structured observation data** | Tool usage is captured with type classification (read/write/execute/search), file tracking, intent detection, and AI-generated narratives — not raw text dumps | +| **No process leaks** | Background workers self-terminate after 5 minutes, use PID-based lock files with stale-lock cleanup, and handle SIGTERM/SIGINT gracefully. No zombie processes, no orphaned workers | +| **No memory leaks** | Hooks run as short-lived processes (not long-running daemons). Database connections close on shutdown. Embedding subprocess has bounded respawn (max 2), pending request timeouts, and graceful cleanup of all timers and queues | + +--- + ## Web Viewer View and manage your memories through a modern web interface. @@ -341,22 +383,60 @@ Workers run automatically after session end. Each worker: ## AI Provider Configuration -AI enrichment supports multiple providers: +AI enrichment uses pluggable providers. Default is `claude-cli` (no API key needed). + +| Provider | Type | Default Model | Notes | +|----------|------|---------------|-------| +| **Claude CLI** | `claude-cli` | `haiku` | Uses `claude --print`, no API key needed | +| **OpenAI** | `openai` | `gpt-4o-mini` | Any OpenAI model | +| **Google Gemini** | `gemini` | `gemini-2.0-flash` | Google AI Studio key | +| **OpenRouter** | `openai` | any | Set `baseUrl` to `https://openrouter.ai/api/v1` | +| **GLM (Zhipu)** | `openai` | any | Set `baseUrl` to `https://open.bigmodel.cn/api/paas/v4` | +| **Ollama** | `openai` | any | Set `baseUrl` to `http://localhost:11434/v1` | + +### Option 1: Environment Variables -| Provider | Config Key | Notes | -|----------|-----------|-------| -| **Claude CLI** (default) | `claude-cli` | Uses `claude --print`, no API key needed | -| **OpenAI** | `openai` | Requires `apiKey` | -| **OpenRouter** | `openrouter` | Requires `apiKey` | -| **Google Gemini** | `gemini` | Requires `apiKey` | -| **GLM (Zhipu)** | `glm` | Requires `apiKey` | +```bash +# OpenAI +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-... + +# Google Gemini +export AGENTKITS_AI_PROVIDER=gemini +export AGENTKITS_AI_API_KEY=AIza... + +# OpenRouter (uses OpenAI-compatible format) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_API_KEY=sk-or-... +export AGENTKITS_AI_BASE_URL=https://openrouter.ai/api/v1 +export AGENTKITS_AI_MODEL=anthropic/claude-3.5-haiku + +# Local Ollama (no API key needed) +export AGENTKITS_AI_PROVIDER=openai +export AGENTKITS_AI_BASE_URL=http://localhost:11434/v1 +export AGENTKITS_AI_MODEL=llama3.2 + +# Disable AI enrichment entirely +export AGENTKITS_AI_ENRICHMENT=false +``` + +### Option 2: Persistent Settings -Configure via CLI: ```bash +# Saved to .claude/memory/settings.json — persists across sessions npx agentkits-memory-hook settings . aiProvider.provider=openai aiProvider.apiKey=sk-... npx agentkits-memory-hook settings . aiProvider.provider=gemini aiProvider.apiKey=AIza... +npx agentkits-memory-hook settings . aiProvider.baseUrl=https://openrouter.ai/api/v1 + +# View current settings +npx agentkits-memory-hook settings . + +# Reset to defaults +npx agentkits-memory-hook settings . --reset ``` +> **Priority:** Environment variables override settings.json. Settings.json overrides defaults. + --- ## Lifecycle Management diff --git a/src/cli/web-viewer.ts b/src/cli/web-viewer.ts index 674d669..70b7a6c 100644 --- a/src/cli/web-viewer.ts +++ b/src/cli/web-viewer.ts @@ -1278,7 +1278,17 @@ function getHTML(): string {

AgentKits Memory Database

-
+
+ + +
+ -
- -
- -
-
-
- -
- - -
- -
-
-
- - -
- -