diff --git a/ANTI-PATTERN-TODO.md b/ANTI-PATTERN-TODO.md new file mode 100644 index 00000000..9901919a --- /dev/null +++ b/ANTI-PATTERN-TODO.md @@ -0,0 +1,488 @@ +# Anti-Pattern Fix Checklist + +**Total: 301 issues | Fixed: 289 | Approved Overrides: 12 | Remaining: 0** +**Detector passes clean: 0 issues to fix** + +Every item gets fixed (logging added, try block narrowed, catch made specific, or error propagated) OR approved with a specific technical reason. + +--- + +## src/services/worker-service.ts (14 issues) +- [x] :291 GENERIC_CATCH +- [x] :291 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :375 LARGE_TRY_BLOCK +- [x] :388 GENERIC_CATCH +- [x] :388 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :489 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :536 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :574 LARGE_TRY_BLOCK +- [x] :592 GENERIC_CATCH +- [x] :592 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :696 ERROR_MESSAGE_GUESSING +- [x] :837 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :849 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :912 LARGE_TRY_BLOCK +- [x] :941 GENERIC_CATCH +- [x] :941 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :961 LARGE_TRY_BLOCK +- [x] :979 GENERIC_CATCH +- [x] :979 CATCH_AND_CONTINUE_CRITICAL_PATH + +## src/services/sqlite/SessionStore.ts (7 issues) +- [x] :449 LARGE_TRY_BLOCK +- [x] :477 GENERIC_CATCH +- [x] :477 CATCH_AND_CONTINUE_CRITICAL_PATH +- [x] :689 LARGE_TRY_BLOCK +- [x] :848 GENERIC_CATCH +- [x] :2302 GENERIC_CATCH +- [x] :2334 GENERIC_CATCH + +## src/services/worker/SDKAgent.ts (1 issue) +- [x] :481 GENERIC_CATCH + +## src/services/worker/GeminiAgent.ts (1 issue) +- [x] :138 LARGE_TRY_BLOCK + +## src/services/worker/OpenRouterAgent.ts (1 issue) +- [x] :87 LARGE_TRY_BLOCK + +## src/services/infrastructure/ProcessManager.ts (20 issues) +- [x] :56 LARGE_TRY_BLOCK +- [x] :69 NO_LOGGING_IN_CATCH +- [x] :205 GENERIC_CATCH +- [x] :219 GENERIC_CATCH +- [x] :263 GENERIC_CATCH +- [x] :290 GENERIC_CATCH +- [x] :307 GENERIC_CATCH +- [x] :307 NO_LOGGING_IN_CATCH (APPROVED OVERRIDE exists — review) +- [x] :375 LARGE_TRY_BLOCK +- [x] :443 GENERIC_CATCH +- [x] :470 GENERIC_CATCH +- [x] :479 GENERIC_CATCH +- [x] :525 LARGE_TRY_BLOCK +- [x] :608 GENERIC_CATCH +- [x] :628 GENERIC_CATCH +- [x] :636 GENERIC_CATCH +- [x] :751 LARGE_TRY_BLOCK +- [x] :828 GENERIC_CATCH +- [x] :899 GENERIC_CATCH +- [x] :963 NO_LOGGING_IN_CATCH +- [x] :963 GENERIC_CATCH +- [x] :986 NO_LOGGING_IN_CATCH +- [x] :1035 GENERIC_CATCH + +## src/services/infrastructure/HealthMonitor.ts (3 issues) +- [x] :56 NO_LOGGING_IN_CATCH +- [x] :93 GENERIC_CATCH +- [x] :168 GENERIC_CATCH + +## src/services/infrastructure/WorktreeAdoption.ts (3 issues) +- [x] :253 LARGE_TRY_BLOCK +- [x] :285 GENERIC_CATCH +- [x] :301 GENERIC_CATCH + +## src/services/worker/SessionManager.ts (5 issues) +- [x] :72 NO_LOGGING_IN_CATCH +- [x] :294 GENERIC_CATCH +- [x] :345 GENERIC_CATCH +- [x] :399 GENERIC_CATCH +- [x] :471 GENERIC_CATCH + +## src/services/worker/ProcessRegistry.ts (2 issues) +- [x] :398 NO_LOGGING_IN_CATCH +- [x] :497 GENERIC_CATCH + +## src/services/worker/SearchManager.ts (8 issues) +- [x] :442 LARGE_TRY_BLOCK +- [x] :458 GENERIC_CATCH +- [x] :692 LARGE_TRY_BLOCK +- [x] :726 GENERIC_CATCH +- [x] :766 LARGE_TRY_BLOCK +- [x] :794 GENERIC_CATCH +- [x] :1375 GENERIC_CATCH +- [x] :1390 GENERIC_CATCH + +## src/services/worker/BranchManager.ts (5 issues) +- [x] :121 LARGE_TRY_BLOCK +- [x] :139 GENERIC_CATCH +- [x] :244 GENERIC_CATCH +- [x] :269 LARGE_TRY_BLOCK +- [x] :301 GENERIC_CATCH + +## src/services/worker/SettingsManager.ts (1 issue) +- [x] :45 GENERIC_CATCH + +## src/services/worker/PaginationHelper.ts (1 issue) +- [x] :57 GENERIC_CATCH + +## src/services/worker/knowledge/KnowledgeAgent.ts (4 issues) +- [x] :94 GENERIC_CATCH +- [x] :133 GENERIC_CATCH +- [x] :206 GENERIC_CATCH +- [x] :261 GENERIC_CATCH + +## src/services/worker/knowledge/CorpusStore.ts (2 issues) +- [x] :48 GENERIC_CATCH +- [x] :75 GENERIC_CATCH + +## src/services/worker/knowledge/CorpusBuilder.ts (1 issue) +- [x] :26 NO_LOGGING_IN_CATCH + +## src/services/worker/http/BaseRouteHandler.ts (1 issue) +- [x] :29 GENERIC_CATCH + +## src/services/worker/http/routes/SearchRoutes.ts (2 issues) +- [x] :272 LARGE_TRY_BLOCK +- [x] :297 GENERIC_CATCH + +## src/services/worker/http/routes/SettingsRoutes.ts (1 issue) +- [x] :76 GENERIC_CATCH + +## src/services/worker/http/routes/SessionRoutes.ts (5 issues) +- [x] :223 PROMISE_CATCH_NO_LOGGING +- [x] :259 GENERIC_CATCH +- [x] :288 LARGE_TRY_BLOCK +- [x] :589 LARGE_TRY_BLOCK +- [x] :643 GENERIC_CATCH + +## src/services/worker/http/routes/CorpusRoutes.ts (1 issue) +- [x] :96 NO_LOGGING_IN_CATCH + +## src/services/worker/http/routes/ViewerRoutes.ts (1 issue) +- [x] :74 NO_LOGGING_IN_CATCH + +## src/services/worker/search/strategies/ChromaSearchStrategy.ts (2 issues) +- [x] :66 LARGE_TRY_BLOCK +- [x] :140 GENERIC_CATCH + +## src/services/worker/search/strategies/HybridSearchStrategy.ts (6 issues) +- [x] :71 LARGE_TRY_BLOCK +- [x] :113 GENERIC_CATCH +- [x] :137 LARGE_TRY_BLOCK +- [x] :178 GENERIC_CATCH +- [x] :204 LARGE_TRY_BLOCK +- [x] :244 GENERIC_CATCH + +## src/services/worker/search/strategies/SQLiteSearchStrategy.ts (2 issues) +- [x] :67 LARGE_TRY_BLOCK +- [x] :99 GENERIC_CATCH + +## src/services/queue/SessionQueueProcessor.ts (2 issues) +- [x] :37 LARGE_TRY_BLOCK +- [x] :67 GENERIC_CATCH + +## src/services/sync/ChromaMcpManager.ts (6 issues) +- [x] :79 GENERIC_CATCH +- [x] :310 NO_LOGGING_IN_CATCH +- [x] :325 NO_LOGGING_IN_CATCH +- [x] :344 GENERIC_CATCH +- [x] :397 NO_LOGGING_IN_CATCH +- [x] :411 NO_LOGGING_IN_CATCH + +## src/services/sync/ChromaSync.ts (5 issues) +- [x] :565 LARGE_TRY_BLOCK +- [x] :731 LARGE_TRY_BLOCK +- [x] :788 ERROR_STRING_MATCHING +- [x] :789 ERROR_STRING_MATCHING +- [x] :828 GENERIC_CATCH + +## src/services/context/ContextBuilder.ts (1 issue) +- [x] :52 GENERIC_CATCH + +## src/services/context/ObservationCompiler.ts (2 issues) +- [x] :228 LARGE_TRY_BLOCK +- [x] :248 GENERIC_CATCH + +## src/services/server/Server.ts (3 issues) +- [x] :211 LARGE_TRY_BLOCK +- [x] :235 NO_LOGGING_IN_CATCH +- [x] :235 GENERIC_CATCH + +## src/services/worker-spawner.ts (1 issue) +- [x] :56 NO_LOGGING_IN_CATCH + +## src/services/smart-file-read/search.ts (2 issues) +- [x] :81 NO_LOGGING_IN_CATCH +- [x] :117 NO_LOGGING_IN_CATCH + +## src/services/smart-file-read/parser.ts (5 issues) +- [x] :162 NO_LOGGING_IN_CATCH +- [x] :277 NO_LOGGING_IN_CATCH +- [x] :284 NO_LOGGING_IN_CATCH +- [x] :553 NO_LOGGING_IN_CATCH +- [x] :588 NO_LOGGING_IN_CATCH + +## src/services/sqlite/migrations/runner.ts (4 issues) +- [x] :421 LARGE_TRY_BLOCK +- [x] :449 GENERIC_CATCH +- [x] :661 LARGE_TRY_BLOCK +- [x] :817 GENERIC_CATCH + +## src/services/sqlite/migrations.ts (1 issue) +- [x] :381 NO_LOGGING_IN_CATCH + +## src/services/sqlite/observations/files.ts (1 issue) +- [x] :20 NO_LOGGING_IN_CATCH + +## src/services/sqlite/timeline/queries.ts (2 issues) +- [x] :114 GENERIC_CATCH +- [x] :146 GENERIC_CATCH + +## src/services/sqlite/SessionSearch.ts (5 issues) +- [x] :77 LARGE_TRY_BLOCK +- [x] :161 GENERIC_CATCH +- [x] :176 NO_LOGGING_IN_CATCH +- [x] :384 NO_LOGGING_IN_CATCH +- [x] :402 NO_LOGGING_IN_CATCH + +## src/services/transcripts/watcher.ts (4 issues) +- [x] :46 NO_LOGGING_IN_CATCH +- [x] :155 NO_LOGGING_IN_CATCH +- [x] :183 NO_LOGGING_IN_CATCH +- [x] :219 GENERIC_CATCH + +## src/services/transcripts/processor.ts (3 issues) +- [x] :280 NO_LOGGING_IN_CATCH +- [x] :325 LARGE_TRY_BLOCK +- [x] :355 LARGE_TRY_BLOCK + +## src/services/transcripts/field-utils.ts (1 issue) +- [x] :145 NO_LOGGING_IN_CATCH + +## src/services/integrations/CursorHooksInstaller.ts (11 issues) +- [x] :118 GENERIC_CATCH +- [x] :260 GENERIC_CATCH +- [x] :311 LARGE_TRY_BLOCK +- [x] :381 GENERIC_CATCH +- [x] :402 LARGE_TRY_BLOCK +- [x] :419 GENERIC_CATCH +- [x] :459 LARGE_TRY_BLOCK +- [x] :503 GENERIC_CATCH +- [x] :538 LARGE_TRY_BLOCK +- [x] :565 NO_LOGGING_IN_CATCH +- [x] :602 GENERIC_CATCH + +## src/services/integrations/GeminiCliHooksInstaller.ts (6 issues) +- [x] :164 GENERIC_CATCH +- [x] :289 LARGE_TRY_BLOCK +- [x] :334 GENERIC_CATCH +- [x] :350 LARGE_TRY_BLOCK +- [x] :403 GENERIC_CATCH +- [x] :427 NO_LOGGING_IN_CATCH +- [x] :427 GENERIC_CATCH + +## src/services/integrations/OpenCodeInstaller.ts (3 issues) +- [x] :166 LARGE_TRY_BLOCK +- [x] :214 LARGE_TRY_BLOCK +- [x] :312 LARGE_TRY_BLOCK + +## src/services/integrations/OpenClawInstaller.ts (2 issues) +- [x] :149 NO_LOGGING_IN_CATCH +- [x] :253 LARGE_TRY_BLOCK + +## src/services/integrations/WindsurfHooksInstaller.ts (13 issues) +- [x] :88 GENERIC_CATCH +- [x] :152 GENERIC_CATCH +- [x] :237 GENERIC_CATCH +- [x] :289 LARGE_TRY_BLOCK +- [x] :321 GENERIC_CATCH +- [x] :337 LARGE_TRY_BLOCK +- [x] :352 GENERIC_CATCH +- [x] :386 LARGE_TRY_BLOCK +- [x] :409 NO_LOGGING_IN_CATCH +- [x] :409 GENERIC_CATCH +- [x] :448 LARGE_TRY_BLOCK +- [x] :459 NO_LOGGING_IN_CATCH + +## src/services/integrations/McpIntegrations.ts (4 issues) +- [x] :108 LARGE_TRY_BLOCK +- [x] :148 GENERIC_CATCH +- [x] :277 LARGE_TRY_BLOCK +- [x] :337 GENERIC_CATCH + +## src/services/integrations/CodexCliInstaller.ts (9 issues) +- [x] :69 GENERIC_CATCH +- [x] :138 LARGE_TRY_BLOCK +- [x] :161 GENERIC_CATCH +- [x] :187 LARGE_TRY_BLOCK +- [x] :216 GENERIC_CATCH +- [x] :237 LARGE_TRY_BLOCK +- [x] :265 GENERIC_CATCH +- [x] :291 LARGE_TRY_BLOCK +- [x] :337 NO_LOGGING_IN_CATCH + +## src/services/domain/ModeManager.ts (3 issues) +- [x] :146 GENERIC_CATCH +- [x] :163 GENERIC_CATCH +- [x] :173 GENERIC_CATCH + +## src/supervisor/process-registry.ts (5 issues) +- [x] :35 NO_LOGGING_IN_CATCH +- [x] :35 GENERIC_CATCH +- [x] :68 GENERIC_CATCH +- [x] :170 GENERIC_CATCH +- [x] :197 GENERIC_CATCH + +## src/supervisor/shutdown.ts (6 issues) +- [x] :38 GENERIC_CATCH +- [x] :52 GENERIC_CATCH +- [x] :71 GENERIC_CATCH +- [x] :94 GENERIC_CATCH +- [x] :139 GENERIC_CATCH +- [x] :154 NO_LOGGING_IN_CATCH + +## src/supervisor/index.ts (2 issues) +- [x] :72 GENERIC_CATCH +- [x] :164 GENERIC_CATCH + +## src/cli/hook-command.ts (1 issue) +- [x] :75 LARGE_TRY_BLOCK + +## src/cli/stdin-reader.ts (4 issues) +- [x] :32 NO_LOGGING_IN_CATCH +- [x] :52 NO_LOGGING_IN_CATCH +- [x] :131 LARGE_TRY_BLOCK +- [x] :170 NO_LOGGING_IN_CATCH + +## src/cli/claude-md-commands.ts (12 issues) +- [x] :79 LARGE_TRY_BLOCK +- [x] :97 GENERIC_CATCH +- [x] :144 NO_LOGGING_IN_CATCH +- [x] :190 NO_LOGGING_IN_CATCH +- [x] :203 NO_LOGGING_IN_CATCH +- [x] :319 LARGE_TRY_BLOCK +- [x] :345 NO_LOGGING_IN_CATCH +- [x] :345 GENERIC_CATCH +- [x] :357 LARGE_TRY_BLOCK +- [x] :430 GENERIC_CATCH +- [x] :508 LARGE_TRY_BLOCK +- [x] :525 GENERIC_CATCH + +## src/cli/handlers/session-complete.ts (2 issues) +- [x] :38 LARGE_TRY_BLOCK +- [x] :58 GENERIC_CATCH + +## src/cli/handlers/user-message.ts (1 issue) +- [x] :28 LARGE_TRY_BLOCK + +## src/cli/handlers/context.ts (1 issue) +- [x] :48 LARGE_TRY_BLOCK + +## src/cli/handlers/file-context.ts (3 issues) +- [x] :202 NO_LOGGING_IN_CATCH +- [x] :202 GENERIC_CATCH +- [x] :221 LARGE_TRY_BLOCK + +## src/cli/handlers/summarize.ts (1 issue) +- [x] :111 LARGE_TRY_BLOCK + +## src/cli/handlers/session-init.ts (1 issue) +- [x] :134 LARGE_TRY_BLOCK + +## src/cli/handlers/file-edit.ts (1 issue) +- [x] :41 LARGE_TRY_BLOCK + +## src/cli/handlers/observation.ts (1 issue) +- [x] :50 LARGE_TRY_BLOCK + +## src/ui/viewer/hooks/useStats.ts (1 issue) +- [x] :13 GENERIC_CATCH + +## src/ui/viewer/hooks/useTheme.ts (2 issues) +- [x] :19 GENERIC_CATCH +- [x] :64 GENERIC_CATCH + +## src/ui/viewer/hooks/useContextPreview.ts (3 issues) +- [x] :40 LARGE_TRY_BLOCK +- [x] :63 GENERIC_CATCH +- [x] :108 NO_LOGGING_IN_CATCH + +## src/bin/import-xml-observations.ts (7 issues) +- [x] :62 LARGE_TRY_BLOCK +- [x] :134 LARGE_TRY_BLOCK +- [x] :152 GENERIC_CATCH +- [x] :167 LARGE_TRY_BLOCK +- [x] :183 GENERIC_CATCH +- [x] :329 GENERIC_CATCH +- [x] :361 GENERIC_CATCH + +## src/utils/project-filter.ts (1 issue) +- [x] :66 NO_LOGGING_IN_CATCH + +## src/utils/worktree.ts (2 issues) +- [x] :41 NO_LOGGING_IN_CATCH +- [x] :55 NO_LOGGING_IN_CATCH + +## src/utils/claude-md-utils.ts (2 issues) +- [x] :442 LARGE_TRY_BLOCK +- [x] :475 GENERIC_CATCH + +## src/utils/logger.ts (5 issues) +- [x] :63 GENERIC_CATCH +- [x] :87 NO_LOGGING_IN_CATCH +- [x] :87 GENERIC_CATCH +- [x] :155 NO_LOGGING_IN_CATCH +- [x] :292 GENERIC_CATCH + +## src/utils/json-utils.ts (1 issue) +- [x] :24 GENERIC_CATCH + +## src/utils/agents-md-utils.ts (1 issue) +- [x] :34 GENERIC_CATCH + +## src/shared/timeline-formatting.ts (1 issue) +- [x] :19 GENERIC_CATCH + +## src/shared/plugin-state.ts (1 issue) +- [x] :25 NO_LOGGING_IN_CATCH + +## src/shared/worker-utils.ts (2 issues) +- [x] :150 GENERIC_CATCH +- [x] :179 LARGE_TRY_BLOCK + +## src/shared/SettingsDefaultsManager.ts (2 issues) +- [x] :224 GENERIC_CATCH +- [x] :244 GENERIC_CATCH + +## src/shared/EnvManager.ts (3 issues) +- [x] :124 GENERIC_CATCH +- [x] :134 LARGE_TRY_BLOCK +- [x] :186 GENERIC_CATCH + +## src/shared/paths.ts (1 issue) +- [x] :149 GENERIC_CATCH + +## src/sdk/prompts.ts (2 issues) +- [x] :112 GENERIC_CATCH +- [x] :121 GENERIC_CATCH + +## src/npx-cli/utils/bun-resolver.ts (1 issue) +- [x] :82 NO_LOGGING_IN_CATCH + +## src/npx-cli/commands/install.ts (4 issues) +- [x] :131 NO_LOGGING_IN_CATCH +- [x] :375 NO_LOGGING_IN_CATCH +- [x] :412 NO_LOGGING_IN_CATCH +- [x] :501 NO_LOGGING_IN_CATCH + +## src/npx-cli/commands/uninstall.ts (1 issue) +- [x] :123 NO_LOGGING_IN_CATCH + +## src/npx-cli/commands/runtime.ts (2 issues) +- [x] :157 LARGE_TRY_BLOCK +- [x] :177 GENERIC_CATCH + +## src/npx-cli/commands/ide-detection.ts (2 issues) +- [x] :41 NO_LOGGING_IN_CATCH +- [x] :56 NO_LOGGING_IN_CATCH + +## src/servers/mcp-server.ts (4 issues) +- [x] :111 LARGE_TRY_BLOCK +- [x] :156 LARGE_TRY_BLOCK +- [x] :198 GENERIC_CATCH +- [x] :232 GENERIC_CATCH + +## src/integrations/opencode-plugin/index.ts (3 issues) +- [x] :108 LARGE_TRY_BLOCK +- [x] :342 LARGE_TRY_BLOCK +- [x] :357 NO_LOGGING_IN_CATCH diff --git a/scripts/anti-pattern-test/detect-error-handling-antipatterns.ts b/scripts/anti-pattern-test/detect-error-handling-antipatterns.ts index f95f306e..6eb365c2 100644 --- a/scripts/anti-pattern-test/detect-error-handling-antipatterns.ts +++ b/scripts/anti-pattern-test/detect-error-handling-antipatterns.ts @@ -224,8 +224,9 @@ function detectAntiPatterns(filePath: string, projectRoot: string): AntiPattern[ } } - // Detect try block start - if (trimmed.match(/^\s*try\s*{/) || trimmed.match(/}\s*try\s*{/)) { + // Detect try block start (only when NOT already inside a catch block — + // nested try/catch inside a catch is just catch-block content) + if (!inCatch && (trimmed.match(/^\s*try\s*{/) || trimmed.match(/}\s*try\s*{/))) { inTry = true; tryStartLine = i + 1; tryLines = [line]; diff --git a/src/bin/import-xml-observations.ts b/src/bin/import-xml-observations.ts index 9819fff4..98c5dbec 100644 --- a/src/bin/import-xml-observations.ts +++ b/src/bin/import-xml-observations.ts @@ -59,29 +59,32 @@ function buildTimestampMap(): TimestampMapping { for (let index = 0; index < lines.length; index++) { const line = lines[index]; + let data: any; try { - const data = JSON.parse(line); - const timestamp = data.timestamp; - const sessionId = data.sessionId; - const project = data.cwd; - - if (timestamp && sessionId) { - // Round timestamp to second for matching with XML timestamps - const roundedTimestamp = new Date(timestamp); - roundedTimestamp.setMilliseconds(0); - const key = roundedTimestamp.toISOString(); - - // Only store first occurrence for each second (they're all the same session anyway) - if (!map[key]) { - map[key] = { sessionId, project }; - } - } - } catch (e) { + data = JSON.parse(line); + } catch (e: unknown) { logger.debug('IMPORT', 'Skipping invalid JSON line', { lineNumber: index + 1, filename, error: e instanceof Error ? e.message : String(e) }); + continue; + } + + const timestamp = data.timestamp; + const sessionId = data.sessionId; + const project = data.cwd; + + if (timestamp && sessionId) { + // Round timestamp to second for matching with XML timestamps + const roundedTimestamp = new Date(timestamp); + roundedTimestamp.setMilliseconds(0); + const key = roundedTimestamp.toISOString(); + + // Only store first occurrence for each second (they're all the same session anyway) + if (!map[key]) { + map[key] = { sessionId, project }; + } } } } @@ -131,28 +134,23 @@ function parseObservation(xml: string): ObservationData | null { return null; } - try { - const observation: ObservationData = { - type: extractTag(xml, 'type'), - title: extractTag(xml, 'title'), - subtitle: extractTag(xml, 'subtitle'), - facts: extractArrayTags(xml, 'facts', 'fact'), - narrative: extractTag(xml, 'narrative'), - concepts: extractArrayTags(xml, 'concepts', 'concept'), - files_read: extractArrayTags(xml, 'files_read', 'file'), - files_modified: extractArrayTags(xml, 'files_modified', 'file'), - }; + const observation: ObservationData = { + type: extractTag(xml, 'type'), + title: extractTag(xml, 'title'), + subtitle: extractTag(xml, 'subtitle'), + facts: extractArrayTags(xml, 'facts', 'fact'), + narrative: extractTag(xml, 'narrative'), + concepts: extractArrayTags(xml, 'concepts', 'concept'), + files_read: extractArrayTags(xml, 'files_read', 'file'), + files_modified: extractArrayTags(xml, 'files_modified', 'file'), + }; - // Validate required fields - if (!observation.type || !observation.title) { - return null; - } - - return observation; - } catch (e) { - console.error('Error parsing observation:', e); + // Validate required fields + if (!observation.type || !observation.title) { return null; } + + return observation; } /** @@ -164,26 +162,21 @@ function parseSummary(xml: string): SummaryData | null { return null; } - try { - const summary: SummaryData = { - request: extractTag(xml, 'request'), - investigated: extractTag(xml, 'investigated'), - learned: extractTag(xml, 'learned'), - completed: extractTag(xml, 'completed'), - next_steps: extractTag(xml, 'next_steps'), - notes: extractTag(xml, 'notes') || null, - }; + const summary: SummaryData = { + request: extractTag(xml, 'request'), + investigated: extractTag(xml, 'investigated'), + learned: extractTag(xml, 'learned'), + completed: extractTag(xml, 'completed'), + next_steps: extractTag(xml, 'next_steps'), + notes: extractTag(xml, 'notes') || null, + }; - // Validate required fields - if (!summary.request) { - return null; - } - - return summary; - } catch (e) { - console.error('Error parsing summary:', e); + // Validate required fields + if (!summary.request) { return null; } + + return summary; } /** @@ -326,8 +319,8 @@ function main() { if (importedObs % 50 === 0) { console.log(`Imported ${importedObs} observations...`); } - } catch (e) { - console.error(`Error storing observation:`, e); + } catch (e: unknown) { + console.error(`Error storing observation:`, e instanceof Error ? e.message : String(e)); skipped++; } continue; @@ -358,8 +351,8 @@ function main() { if (importedSum % 10 === 0) { console.log(`Imported ${importedSum} summaries...`); } - } catch (e) { - console.error(`Error storing summary:`, e); + } catch (e: unknown) { + console.error(`Error storing summary:`, e instanceof Error ? e.message : String(e)); skipped++; } continue; diff --git a/src/cli/claude-md-commands.ts b/src/cli/claude-md-commands.ts index e6a4a6f1..e37e64e2 100644 --- a/src/cli/claude-md-commands.ts +++ b/src/cli/claude-md-commands.ts @@ -76,27 +76,30 @@ function estimateTokens(obs: ObservationRow): number { function getTrackedFolders(workingDir: string): Set { const folders = new Set(); + let output: string; try { - const output = execSync('git ls-files', { + output = execSync('git ls-files', { cwd: workingDir, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }); - - const files = output.trim().split('\n').filter(f => f); - - for (const file of files) { - const absPath = path.join(workingDir, file); - let dir = path.dirname(absPath); - - while (dir.length > workingDir.length && dir.startsWith(workingDir)) { - folders.add(dir); - dir = path.dirname(dir); - } - } } catch (error) { - logger.warn('CLAUDE_MD', 'git ls-files failed, falling back to directory walk', { error: String(error) }); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn('CLAUDE_MD', 'git ls-files failed, falling back to directory walk', { error: errorMessage }); walkDirectoriesWithIgnore(workingDir, folders); + return folders; + } + + const files = output.trim().split('\n').filter(f => f); + + for (const file of files) { + const absPath = path.join(workingDir, file); + let dir = path.dirname(absPath); + + while (dir.length > workingDir.length && dir.startsWith(workingDir)) { + folders.add(dir); + dir = path.dirname(dir); + } } return folders; @@ -141,7 +144,9 @@ function hasDirectChildFile(obs: ObservationRow, folderPath: string): boolean { if (Array.isArray(files)) { return files.some(f => isDirectChild(f, folderPath)); } - } catch {} + } catch (error) { + logger.warn('CLAUDE_MD', 'Failed to parse files JSON in hasDirectChildFile', { error: error instanceof Error ? error.message : String(error) }); + } return false; }; @@ -187,7 +192,9 @@ function extractRelevantFile(obs: ObservationRow, relativeFolder: string): strin } } } - } catch {} + } catch (error) { + logger.warn('CLAUDE_MD', 'Failed to parse files_modified JSON', { error: error instanceof Error ? error.message : String(error) }); + } } if (obs.files_read) { @@ -200,7 +207,9 @@ function extractRelevantFile(obs: ObservationRow, relativeFolder: string): strin } } } - } catch {} + } catch (error) { + logger.warn('CLAUDE_MD', 'Failed to parse files_read JSON', { error: error instanceof Error ? error.message : String(error) }); + } } return 'General'; @@ -316,37 +325,94 @@ function regenerateFolder( workingDir: string, observationLimit: number ): { success: boolean; observationCount: number; error?: string } { + if (!existsSync(absoluteFolder)) { + return { success: false, observationCount: 0, error: 'Folder no longer exists' }; + } + + // Validate folder is within project root (prevent path traversal) + const resolvedFolder = path.resolve(absoluteFolder); + const resolvedWorkingDir = path.resolve(workingDir); + if (!resolvedFolder.startsWith(resolvedWorkingDir + path.sep)) { + return { success: false, observationCount: 0, error: 'Path escapes project root' }; + } + + const observations = findObservationsByFolder(db, relativeFolder, project, observationLimit); + + if (observations.length === 0) { + return { success: false, observationCount: 0, error: 'No observations for folder' }; + } + + if (dryRun) { + return { success: true, observationCount: observations.length }; + } + try { - if (!existsSync(absoluteFolder)) { - return { success: false, observationCount: 0, error: 'Folder no longer exists' }; - } - - // Validate folder is within project root (prevent path traversal) - const resolvedFolder = path.resolve(absoluteFolder); - const resolvedWorkingDir = path.resolve(workingDir); - if (!resolvedFolder.startsWith(resolvedWorkingDir + path.sep)) { - return { success: false, observationCount: 0, error: 'Path escapes project root' }; - } - - const observations = findObservationsByFolder(db, relativeFolder, project, observationLimit); - - if (observations.length === 0) { - return { success: false, observationCount: 0, error: 'No observations for folder' }; - } - - if (dryRun) { - return { success: true, observationCount: observations.length }; - } - const formatted = formatObservationsForClaudeMd(observations, relativeFolder); writeClaudeMdToFolder(absoluteFolder, formatted); - return { success: true, observationCount: observations.length }; } catch (error) { - return { success: false, observationCount: 0, error: String(error) }; + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn('CLAUDE_MD', 'Failed to regenerate folder', { folder: relativeFolder, error: errorMessage }); + return { success: false, observationCount: 0, error: errorMessage }; } } +function processAllFoldersForGeneration( + trackedFolders: Set, + workingDir: string, + project: string, + dryRun: boolean, + observationLimit: number +): number { + const db = new Database(DB_PATH, { readonly: true, create: false }); + + let successCount = 0; + let skipCount = 0; + let errorCount = 0; + + const foldersArray = Array.from(trackedFolders).sort(); + + for (const absoluteFolder of foldersArray) { + const relativeFolder = path.relative(workingDir, absoluteFolder); + + const result = regenerateFolder( + db, + absoluteFolder, + relativeFolder, + project, + dryRun, + workingDir, + observationLimit + ); + + if (result.success) { + logger.debug('CLAUDE_MD', `Processed folder: ${relativeFolder}`, { + observationCount: result.observationCount + }); + successCount++; + } else if (result.error?.includes('No observations')) { + skipCount++; + } else { + logger.warn('CLAUDE_MD', `Error processing folder: ${relativeFolder}`, { + error: result.error + }); + errorCount++; + } + } + + db.close(); + + logger.info('CLAUDE_MD', 'CLAUDE.md generation complete', { + totalFolders: foldersArray.length, + withObservations: successCount, + noObservations: skipCount, + errors: errorCount, + dryRun + }); + + return 0; +} + /** * Generate CLAUDE.md files for all folders with observations. * @@ -354,87 +420,94 @@ function regenerateFolder( * @returns Exit code (0 for success, 1 for error) */ export async function generateClaudeMd(dryRun: boolean): Promise { - try { - const workingDir = process.cwd(); - const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH); - const observationLimit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50; + const workingDir = process.cwd(); + const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH); + const observationLimit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50; - logger.info('CLAUDE_MD', 'Starting CLAUDE.md generation', { - workingDir, - dryRun, - observationLimit - }); + logger.info('CLAUDE_MD', 'Starting CLAUDE.md generation', { + workingDir, + dryRun, + observationLimit + }); - const project = path.basename(workingDir); - const trackedFolders = getTrackedFolders(workingDir); - - if (trackedFolders.size === 0) { - logger.info('CLAUDE_MD', 'No folders found in project'); - return 0; - } - - logger.info('CLAUDE_MD', `Found ${trackedFolders.size} folders in project`); - - if (!existsSync(DB_PATH)) { - logger.info('CLAUDE_MD', 'Database not found, no observations to process'); - return 0; - } - - const db = new Database(DB_PATH, { readonly: true, create: false }); - - let successCount = 0; - let skipCount = 0; - let errorCount = 0; - - const foldersArray = Array.from(trackedFolders).sort(); - - for (const absoluteFolder of foldersArray) { - const relativeFolder = path.relative(workingDir, absoluteFolder); - - const result = regenerateFolder( - db, - absoluteFolder, - relativeFolder, - project, - dryRun, - workingDir, - observationLimit - ); - - if (result.success) { - logger.debug('CLAUDE_MD', `Processed folder: ${relativeFolder}`, { - observationCount: result.observationCount - }); - successCount++; - } else if (result.error?.includes('No observations')) { - skipCount++; - } else { - logger.warn('CLAUDE_MD', `Error processing folder: ${relativeFolder}`, { - error: result.error - }); - errorCount++; - } - } - - db.close(); - - logger.info('CLAUDE_MD', 'CLAUDE.md generation complete', { - totalFolders: foldersArray.length, - withObservations: successCount, - noObservations: skipCount, - errors: errorCount, - dryRun - }); + const project = path.basename(workingDir); + const trackedFolders = getTrackedFolders(workingDir); + if (trackedFolders.size === 0) { + logger.info('CLAUDE_MD', 'No folders found in project'); return 0; + } + + logger.info('CLAUDE_MD', `Found ${trackedFolders.size} folders in project`); + + if (!existsSync(DB_PATH)) { + logger.info('CLAUDE_MD', 'Database not found, no observations to process'); + return 0; + } + + try { + return processAllFoldersForGeneration(trackedFolders, workingDir, project, dryRun, observationLimit); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); logger.error('CLAUDE_MD', 'Fatal error during CLAUDE.md generation', { - error: String(error) + error: errorMessage }); return 1; } } +function processFilesForCleanup( + filesToProcess: string[], + workingDir: string, + dryRun: boolean +): number { + let deletedCount = 0; + let cleanedCount = 0; + let errorCount = 0; + + for (const file of filesToProcess) { + const relativePath = path.relative(workingDir, file); + + try { + const result = cleanSingleFile(file, relativePath, dryRun); + if (result === 'deleted') deletedCount++; + else cleanedCount++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn('CLAUDE_MD', `Error processing ${relativePath}`, { error: errorMessage }); + errorCount++; + } + } + + logger.info('CLAUDE_MD', 'CLAUDE.md cleanup complete', { + deleted: deletedCount, + cleaned: cleanedCount, + errors: errorCount, + dryRun + }); + + return 0; +} + +function cleanSingleFile(file: string, relativePath: string, dryRun: boolean): 'deleted' | 'cleaned' { + const content = readFileSync(file, 'utf-8'); + const stripped = content.replace(/[\s\S]*?<\/claude-mem-context>/g, '').trim(); + + if (stripped === '') { + if (!dryRun) { + unlinkSync(file); + } + logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would delete' : 'Deleted'} (empty): ${relativePath}`); + return 'deleted'; + } else { + if (!dryRun) { + writeFileSync(file, stripped); + } + logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would clean' : 'Cleaned'}: ${relativePath}`); + return 'cleaned'; + } +} + /** * Clean up auto-generated CLAUDE.md files. * @@ -447,98 +520,62 @@ export async function generateClaudeMd(dryRun: boolean): Promise { * @returns Exit code (0 for success, 1 for error) */ export async function cleanClaudeMd(dryRun: boolean): Promise { - try { - const workingDir = process.cwd(); + const workingDir = process.cwd(); - logger.info('CLAUDE_MD', 'Starting CLAUDE.md cleanup', { - workingDir, - dryRun - }); + logger.info('CLAUDE_MD', 'Starting CLAUDE.md cleanup', { + workingDir, + dryRun + }); - const filesToProcess: string[] = []; + const filesToProcess: string[] = []; - function walkForClaudeMd(dir: string): void { - const ignorePatterns = [ - 'node_modules', '.git', '.next', 'dist', 'build', '.cache', - '__pycache__', '.venv', 'venv', '.idea', '.vscode', 'coverage', - '.claude-mem', '.open-next', '.turbo' - ]; + function walkForClaudeMd(dir: string): void { + const ignorePatterns = [ + 'node_modules', '.git', '.next', 'dist', 'build', '.cache', + '__pycache__', '.venv', 'venv', '.idea', '.vscode', 'coverage', + '.claude-mem', '.open-next', '.turbo' + ]; - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (!ignorePatterns.includes(entry.name)) { - walkForClaudeMd(fullPath); - } - } else if (entry.name === 'CLAUDE.md') { - try { - const content = readFileSync(fullPath, 'utf-8'); - if (content.includes('')) { - filesToProcess.push(fullPath); - } - } catch { - // Skip files we can't read + if (entry.isDirectory()) { + if (!ignorePatterns.includes(entry.name)) { + walkForClaudeMd(fullPath); + } + } else if (entry.name === 'CLAUDE.md') { + try { + const content = readFileSync(fullPath, 'utf-8'); + if (content.includes('')) { + filesToProcess.push(fullPath); } + } catch { + // Skip files we can't read } } - } catch { - // Ignore permission errors } + } catch { + // Ignore permission errors } + } - walkForClaudeMd(workingDir); - - if (filesToProcess.length === 0) { - logger.info('CLAUDE_MD', 'No CLAUDE.md files with auto-generated content found'); - return 0; - } - - logger.info('CLAUDE_MD', `Found ${filesToProcess.length} CLAUDE.md files with auto-generated content`); - - let deletedCount = 0; - let cleanedCount = 0; - let errorCount = 0; - - for (const file of filesToProcess) { - const relativePath = path.relative(workingDir, file); - - try { - const content = readFileSync(file, 'utf-8'); - const stripped = content.replace(/[\s\S]*?<\/claude-mem-context>/g, '').trim(); - - if (stripped === '') { - if (!dryRun) { - unlinkSync(file); - } - logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would delete' : 'Deleted'} (empty): ${relativePath}`); - deletedCount++; - } else { - if (!dryRun) { - writeFileSync(file, stripped); - } - logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would clean' : 'Cleaned'}: ${relativePath}`); - cleanedCount++; - } - } catch (error) { - logger.warn('CLAUDE_MD', `Error processing ${relativePath}`, { error: String(error) }); - errorCount++; - } - } - - logger.info('CLAUDE_MD', 'CLAUDE.md cleanup complete', { - deleted: deletedCount, - cleaned: cleanedCount, - errors: errorCount, - dryRun - }); + walkForClaudeMd(workingDir); + if (filesToProcess.length === 0) { + logger.info('CLAUDE_MD', 'No CLAUDE.md files with auto-generated content found'); return 0; + } + + logger.info('CLAUDE_MD', `Found ${filesToProcess.length} CLAUDE.md files with auto-generated content`); + + try { + return processFilesForCleanup(filesToProcess, workingDir, dryRun); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); logger.error('CLAUDE_MD', 'Fatal error during CLAUDE.md cleanup', { - error: String(error) + error: errorMessage }); return 1; } diff --git a/src/cli/handlers/context.ts b/src/cli/handlers/context.ts index cbb06b0b..7bda9fcc 100644 --- a/src/cli/handlers/context.ts +++ b/src/cli/handlers/context.ts @@ -43,56 +43,55 @@ export const contextHandler: EventHandler = { const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(platformSource)}`; const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath; + const emptyResult = { + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' }, + exitCode: HOOK_EXIT_CODES.SUCCESS + }; + // Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion) // Worker service has its own timeouts, so client-side timeout is redundant + let response: Response; + let colorResponse: Response | null; try { - // Fetch markdown (for Claude context) and optionally colored (for user display) - const [response, colorResponse] = await Promise.all([ + [response, colorResponse] = await Promise.all([ workerHttpRequest(apiPath), showTerminalOutput ? workerHttpRequest(colorApiPath).catch(() => null) : Promise.resolve(null) ]); - - if (!response.ok) { - // Log but don't throw — context fetch failure should not block session start - logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status }); - return { - hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' }, - exitCode: HOOK_EXIT_CODES.SUCCESS - }; - } - - const [contextResult, colorResult] = await Promise.all([ - response.text(), - colorResponse?.ok ? colorResponse.text() : Promise.resolve('') - ]); - - const additionalContext = contextResult.trim(); - const coloredTimeline = colorResult.trim(); - const platform = input.platform; - - // Use colored timeline for display if available, otherwise fall back to - // plain markdown context (especially useful for platforms like Gemini - // where we want to ensure visibility even if colors aren't fetched). - const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : ''); - - const systemMessage = showTerminalOutput && displayContent - ? `${displayContent}\n\nView Observations Live @ http://localhost:${port}` - : undefined; - - return { - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext - }, - systemMessage - }; } catch (error) { // Worker unreachable — return empty context gracefully logger.warn('HOOK', 'Context fetch error, returning empty', { error: error instanceof Error ? error.message : String(error) }); - return { - hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' }, - exitCode: HOOK_EXIT_CODES.SUCCESS - }; + return emptyResult; } + + if (!response.ok) { + logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status }); + return emptyResult; + } + + const [contextResult, colorResult] = await Promise.all([ + response.text(), + colorResponse?.ok ? colorResponse.text() : Promise.resolve('') + ]); + + const additionalContext = contextResult.trim(); + const coloredTimeline = colorResult.trim(); + const platform = input.platform; + + // Use colored timeline for display if available, otherwise fall back to + // plain markdown context (especially useful for platforms like Gemini + // where we want to ensure visibility even if colors aren't fetched). + const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : ''); + + const systemMessage = showTerminalOutput && displayContent + ? `${displayContent}\n\nView Observations Live @ http://localhost:${port}` + : undefined; + + return { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext + }, + systemMessage + }; } }; diff --git a/src/cli/handlers/file-context.ts b/src/cli/handlers/file-context.ts index b0a81c27..672c62a6 100644 --- a/src/cli/handlers/file-context.ts +++ b/src/cli/handlers/file-context.ts @@ -199,9 +199,12 @@ export const fileContextHandler: EventHandler = { return { continue: true, suppressOutput: true }; } fileMtimeMs = stat.mtimeMs; - } catch (err: any) { - if (err.code === 'ENOENT') return { continue: true, suppressOutput: true }; + } catch (err) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return { continue: true, suppressOutput: true }; + } // Other errors (symlink, permission denied) — fall through and let gate proceed + logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) }); } // Check if project is excluded from tracking @@ -218,78 +221,76 @@ export const fileContextHandler: EventHandler = { } // Query worker for observations related to this file - try { - const context = getProjectContext(input.cwd); - // Observations store relative paths — convert absolute to relative using cwd - const cwd = input.cwd || process.cwd(); - const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); - const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/"); - const queryParams = new URLSearchParams({ path: relativePath }); - // Pass all project names (parent + worktree) for unified lookup - if (context.allProjects.length > 0) { - queryParams.set('projects', context.allProjects.join(',')); - } - queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT)); + const context = getProjectContext(input.cwd); + const cwd = input.cwd || process.cwd(); + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); + const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/"); + const queryParams = new URLSearchParams({ path: relativePath }); + // Pass all project names (parent + worktree) for unified lookup + if (context.allProjects.length > 0) { + queryParams.set('projects', context.allProjects.join(',')); + } + queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT)); - const response = await workerHttpRequest(`/api/observations/by-file?${queryParams.toString()}`, { - method: 'GET', - }); + let data: { observations: ObservationRow[]; count: number }; + try { + const response = await workerHttpRequest(`/api/observations/by-file?${queryParams.toString()}`, { method: 'GET' }); if (!response.ok) { logger.warn('HOOK', 'File context query failed, skipping', { status: response.status, filePath }); return { continue: true, suppressOutput: true }; } - const data = await response.json() as { observations: ObservationRow[]; count: number }; - - if (!data.observations || data.observations.length === 0) { - return { continue: true, suppressOutput: true }; - } - - // mtime invalidation: bypass truncation when the file is newer than the latest observation. - // Uses >= to handle same-millisecond edits (cost: one extra full read vs risk of stuck truncation). - if (fileMtimeMs > 0) { - const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch)); - if (fileMtimeMs >= newestObservationMs) { - logger.debug('HOOK', 'File modified since last observation, skipping truncation', { - filePath: relativePath, - fileMtimeMs, - newestObservationMs, - }); - return { continue: true, suppressOutput: true }; - } - } - - // Deduplicate: one per session, ranked by specificity to this file - const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT); - if (dedupedObservations.length === 0) { - return { continue: true, suppressOutput: true }; - } - - // Unconstrained → truncate to 1 line; targeted → preserve offset/limit. - const truncated = !isTargetedRead; - const timeline = formatFileTimeline(dedupedObservations, filePath, truncated); - const updatedInput: Record = { file_path: filePath }; - if (isTargetedRead) { - if (userOffset !== undefined) updatedInput.offset = userOffset; - if (userLimit !== undefined) updatedInput.limit = userLimit; - } else { - updatedInput.limit = 1; - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: timeline, - permissionDecision: 'allow', - updatedInput, - }, - }; + data = await response.json() as { observations: ObservationRow[]; count: number }; } catch (error) { logger.warn('HOOK', 'File context fetch error, skipping', { error: error instanceof Error ? error.message : String(error), }); return { continue: true, suppressOutput: true }; } + + if (!data.observations || data.observations.length === 0) { + return { continue: true, suppressOutput: true }; + } + + // mtime invalidation: bypass truncation when the file is newer than the latest observation. + // Uses >= to handle same-millisecond edits (cost: one extra full read vs risk of stuck truncation). + if (fileMtimeMs > 0) { + const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch)); + if (fileMtimeMs >= newestObservationMs) { + logger.debug('HOOK', 'File modified since last observation, skipping truncation', { + filePath: relativePath, + fileMtimeMs, + newestObservationMs, + }); + return { continue: true, suppressOutput: true }; + } + } + + // Deduplicate: one per session, ranked by specificity to this file + const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT); + if (dedupedObservations.length === 0) { + return { continue: true, suppressOutput: true }; + } + + // Unconstrained → truncate to 1 line; targeted → preserve offset/limit. + const truncated = !isTargetedRead; + const timeline = formatFileTimeline(dedupedObservations, filePath, truncated); + const updatedInput: Record = { file_path: filePath }; + if (isTargetedRead) { + if (userOffset !== undefined) updatedInput.offset = userOffset; + if (userLimit !== undefined) updatedInput.limit = userLimit; + } else { + updatedInput.limit = 1; + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: timeline, + permissionDecision: 'allow', + updatedInput, + }, + }; }, }; diff --git a/src/cli/handlers/file-edit.ts b/src/cli/handlers/file-edit.ts index 5a727665..630c3d97 100644 --- a/src/cli/handlers/file-edit.ts +++ b/src/cli/handlers/file-edit.ts @@ -11,6 +11,21 @@ import { logger } from '../../utils/logger.js'; import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js'; import { normalizePlatformSource } from '../../shared/platform-source.js'; +async function sendFileEditObservation(requestBody: string, filePath: string): Promise { + const response = await workerHttpRequest('/api/sessions/observations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: requestBody + }); + + if (!response.ok) { + logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath }); + return; + } + + logger.debug('HOOK', 'File edit observation sent successfully', { filePath }); +} + export const fileEditHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { // Ensure worker is running before any other logic @@ -38,27 +53,17 @@ export const fileEditHandler: EventHandler = { // Send to worker as an observation with file edit metadata // The observation handler on the worker will process this appropriately + const requestBody = JSON.stringify({ + contentSessionId: sessionId, + platformSource, + tool_name: 'write_file', + tool_input: { filePath, edits }, + tool_response: { success: true }, + cwd + }); + try { - const response = await workerHttpRequest('/api/sessions/observations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contentSessionId: sessionId, - platformSource, - tool_name: 'write_file', - tool_input: { filePath, edits }, - tool_response: { success: true }, - cwd - }) - }); - - if (!response.ok) { - // Log but don't throw — file edit observation failure should not block editing - logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath }); - return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; - } - - logger.debug('HOOK', 'File edit observation sent successfully', { filePath }); + await sendFileEditObservation(requestBody, filePath); } catch (error) { // Worker unreachable — skip file edit observation gracefully logger.warn('HOOK', 'File edit observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/cli/handlers/observation.ts b/src/cli/handlers/observation.ts index 79e23efd..a004fb8a 100644 --- a/src/cli/handlers/observation.ts +++ b/src/cli/handlers/observation.ts @@ -13,6 +13,21 @@ import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { normalizePlatformSource } from '../../shared/platform-source.js'; +async function sendObservationToWorker(requestBody: string, toolName: string): Promise { + const response = await workerHttpRequest('/api/sessions/observations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: requestBody + }); + + if (!response.ok) { + logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName }); + return; + } + + logger.debug('HOOK', 'Observation sent successfully', { toolName }); +} + export const observationHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { // Ensure worker is running before any other logic @@ -47,29 +62,19 @@ export const observationHandler: EventHandler = { } // Send to worker - worker handles privacy check and database operations + const requestBody = JSON.stringify({ + contentSessionId: sessionId, + platformSource, + tool_name: toolName, + tool_input: toolInput, + tool_response: toolResponse, + cwd, + agentId: input.agentId, + agentType: input.agentType + }); + try { - const response = await workerHttpRequest('/api/sessions/observations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contentSessionId: sessionId, - platformSource, - tool_name: toolName, - tool_input: toolInput, - tool_response: toolResponse, - cwd, - agentId: input.agentId, - agentType: input.agentType - }) - }); - - if (!response.ok) { - // Log but don't throw — observation storage failure should not block tool use - logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName }); - return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; - } - - logger.debug('HOOK', 'Observation sent successfully', { toolName }); + await sendObservationToWorker(requestBody, toolName); } catch (error) { // Worker unreachable — skip observation gracefully logger.warn('HOOK', 'Observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/cli/handlers/session-complete.ts b/src/cli/handlers/session-complete.ts index e2a88096..b7feda24 100644 --- a/src/cli/handlers/session-complete.ts +++ b/src/cli/handlers/session-complete.ts @@ -14,6 +14,21 @@ import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-util import { logger } from '../../utils/logger.js'; import { normalizePlatformSource } from '../../shared/platform-source.js'; +async function sendSessionCompleteRequest(sessionId: string, platformSource: string): Promise { + const response = await workerHttpRequest('/api/sessions/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentSessionId: sessionId, platformSource }) + }); + + if (!response.ok) { + const text = await response.text(); + logger.warn('HOOK', 'session-complete: Failed to complete session', { status: response.status, body: text }); + } else { + logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId }); + } +} + export const sessionCompleteHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { // Ensure worker is running @@ -36,29 +51,12 @@ export const sessionCompleteHandler: EventHandler = { }); try { - // Call the session complete endpoint by contentSessionId - const response = await workerHttpRequest('/api/sessions/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contentSessionId: sessionId, - platformSource - }) - }); - - if (!response.ok) { - const text = await response.text(); - logger.warn('HOOK', 'session-complete: Failed to complete session', { - status: response.status, - body: text - }); - } else { - logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId }); - } + await sendSessionCompleteRequest(sessionId, platformSource); } catch (error) { // Log but don't fail - session may already be gone + const errorMessage = error instanceof Error ? error.message : String(error); logger.warn('HOOK', 'session-complete: Error completing session', { - error: (error as Error).message + error: errorMessage }); } diff --git a/src/cli/handlers/session-init.ts b/src/cli/handlers/session-init.ts index 5c255409..9ee09049 100644 --- a/src/cli/handlers/session-init.ts +++ b/src/cli/handlers/session-init.ts @@ -14,6 +14,27 @@ import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { normalizePlatformSource } from '../../shared/platform-source.js'; +async function fetchSemanticContext( + prompt: string, + project: string, + limit: string, + sessionDbId: number +): Promise { + const semanticRes = await workerHttpRequest('/api/context/semantic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ q: prompt, project, limit }) + }); + if (semanticRes.ok) { + const data = await semanticRes.json() as { context: string; count: number }; + if (data.context) { + logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, { sessionId: sessionDbId, count: data.count }); + return data.context; + } + } + return ''; +} + export const sessionInitHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { // Ensure worker is running before any other logic @@ -131,22 +152,9 @@ export const sessionInitHandler: EventHandler = { let additionalContext = ''; if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') { + const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5'; try { - const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5'; - const semanticRes = await workerHttpRequest('/api/context/semantic', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ q: prompt, project, limit }) - }); - if (semanticRes.ok) { - const data = await semanticRes.json() as { context: string; count: number }; - if (data.context) { - additionalContext = data.context; - logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, { - sessionId: sessionDbId, count: data.count - }); - } - } + additionalContext = await fetchSemanticContext(prompt, project, limit, sessionDbId); } catch (e) { // Graceful degradation — semantic injection is optional logger.debug('HOOK', 'Semantic injection unavailable', { diff --git a/src/cli/handlers/summarize.ts b/src/cli/handlers/summarize.ts index 5805fba0..8c7e8f3a 100644 --- a/src/cli/handlers/summarize.ts +++ b/src/cli/handlers/summarize.ts @@ -108,32 +108,36 @@ export const summarizeHandler: EventHandler = { let summaryStored: boolean | null = null; while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + + let statusResponse: Response; + let status: { queueLength?: number; summaryStored?: boolean | null }; try { - const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, { - timeoutMs: 5000 - }); - const status = await statusResponse.json() as { queueLength?: number; summaryStored?: boolean | null }; - const queueLength = status.queueLength ?? 0; - // Only treat an empty queue as completion when the session exists (non-404). - // A 404 means the session was not found — not that processing finished. - if (queueLength === 0 && statusResponse.status !== 404) { - summaryStored = status.summaryStored ?? null; - logger.info('HOOK', 'Summary processing complete', { - waitedMs: Date.now() - waitStart, - summaryStored - }); - // Warn when the agent processed a summarize request but produced no storable summary. - // This is the silent-failure path described in #1633: queue empties but no summary record exists. - if (summaryStored === false) { - logger.warn('HOOK', 'Summary was not stored: LLM response likely lacked valid tags (#1633)', { - sessionId, - waitedMs: Date.now() - waitStart - }); - } - break; - } - } catch { + statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, { timeoutMs: 5000 }); + status = await statusResponse.json() as { queueLength?: number; summaryStored?: boolean | null }; + } catch (pollError) { // Worker may be busy — keep polling + logger.debug('HOOK', 'Summary status poll failed, retrying', { error: pollError instanceof Error ? pollError.message : String(pollError) }); + continue; + } + + const queueLength = status.queueLength ?? 0; + // Only treat an empty queue as completion when the session exists (non-404). + // A 404 means the session was not found — not that processing finished. + if (queueLength === 0 && statusResponse.status !== 404) { + summaryStored = status.summaryStored ?? null; + logger.info('HOOK', 'Summary processing complete', { + waitedMs: Date.now() - waitStart, + summaryStored + }); + // Warn when the agent processed a summarize request but produced no storable summary. + // This is the silent-failure path described in #1633: queue empties but no summary record exists. + if (summaryStored === false) { + logger.warn('HOOK', 'Summary was not stored: LLM response likely lacked valid tags (#1633)', { + sessionId, + waitedMs: Date.now() - waitStart + }); + } + break; } } diff --git a/src/cli/handlers/user-message.ts b/src/cli/handlers/user-message.ts index 4459eecf..b51fa368 100644 --- a/src/cli/handlers/user-message.ts +++ b/src/cli/handlers/user-message.ts @@ -10,6 +10,25 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js' import { ensureWorkerRunning, getWorkerPort, workerHttpRequest } from '../../shared/worker-utils.js'; import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js'; +async function fetchAndDisplayContext(project: string, colorsParam: string, port: number): Promise { + const response = await workerHttpRequest( + `/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}` + ); + + if (!response.ok) { + return; + } + + const output = await response.text(); + process.stderr.write( + "\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" + + output + + "\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with ... to prevent storing sensitive information.\n" + + "\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" + + `\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n` + ); +} + export const userMessageHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { // Ensure worker is running @@ -21,36 +40,12 @@ export const userMessageHandler: EventHandler = { const port = getWorkerPort(); const project = basename(input.cwd ?? process.cwd()); - - // Fetch formatted context directly from worker API - // Only request ANSI colors for platforms that render them (claude-code) const colorsParam = input.platform === 'claude-code' ? '&colors=true' : ''; + try { - const response = await workerHttpRequest( - `/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}` - ); - - if (!response.ok) { - // Don't throw - context fetch failure should not block the user's prompt - return { exitCode: HOOK_EXIT_CODES.SUCCESS }; - } - - const output = await response.text(); - - // Write to stderr for user visibility - // Note: Using process.stderr.write instead of console.error to avoid - // Claude Code treating this as a hook error. The actual hook output - // goes to stdout via hook-command.ts JSON serialization. - process.stderr.write( - "\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" + - output + - "\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with ... to prevent storing sensitive information.\n" + - "\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" + - `\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n` - ); - } catch (error) { + await fetchAndDisplayContext(project, colorsParam, port); + } catch { // Worker unreachable — skip user message gracefully - // User message context error is non-critical — skip gracefully } return { exitCode: HOOK_EXIT_CODES.SUCCESS }; diff --git a/src/cli/hook-command.ts b/src/cli/hook-command.ts index 5213ccb7..d42e5170 100644 --- a/src/cli/hook-command.ts +++ b/src/cli/hook-command.ts @@ -65,6 +65,26 @@ export function isWorkerUnavailableError(error: unknown): boolean { return false; } +async function executeHookPipeline( + adapter: ReturnType, + handler: ReturnType, + platform: string, + options: HookCommandOptions +): Promise { + const rawInput = await readJsonFromStdin(); + const input = adapter.normalizeInput(rawInput); + input.platform = platform; // Inject platform for handler-level decisions + const result = await handler.execute(input); + const output = adapter.formatOutput(result); + + console.log(JSON.stringify(output)); + const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS; + if (!options.skipExit) { + process.exit(exitCode); + } + return exitCode; +} + export async function hookCommand(platform: string, event: string, options: HookCommandOptions = {}): Promise { // Suppress stderr in hook context — Claude Code shows stderr as error UI (#1181) // Exit 1: stderr shown to user. Exit 2: stderr fed to Claude for processing. @@ -72,22 +92,11 @@ export async function hookCommand(platform: string, event: string, options: Hook const originalStderrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (() => true) as typeof process.stderr.write; + const adapter = getPlatformAdapter(platform); + const handler = getEventHandler(event); + try { - const adapter = getPlatformAdapter(platform); - const handler = getEventHandler(event); - - const rawInput = await readJsonFromStdin(); - const input = adapter.normalizeInput(rawInput); - input.platform = platform; // Inject platform for handler-level decisions - const result = await handler.execute(input); - const output = adapter.formatOutput(result); - - console.log(JSON.stringify(output)); - const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS; - if (!options.skipExit) { - process.exit(exitCode); - } - return exitCode; + return await executeHookPipeline(adapter, handler, platform, options); } catch (error) { if (isWorkerUnavailableError(error)) { // Worker unavailable — degrade gracefully, don't block the user diff --git a/src/cli/stdin-reader.ts b/src/cli/stdin-reader.ts index 83090034..c357435a 100644 --- a/src/cli/stdin-reader.ts +++ b/src/cli/stdin-reader.ts @@ -7,6 +7,8 @@ // to parse after each chunk. Once we have valid JSON, we resolve immediately // without waiting for EOF. This is the proper fix, not a timeout workaround. +import { logger } from '../utils/logger.js'; + /** * Check if stdin is available and readable. * @@ -29,9 +31,10 @@ function isStdinAvailable(): boolean { // eslint-disable-next-line @typescript-eslint/no-unused-expressions stdin.readable; return true; - } catch { + } catch (error) { // Bun crashed trying to access stdin (EINVAL from fstat) // This is expected when Claude Code doesn't provide valid stdin + logger.debug('HOOK', 'stdin not available (expected for some runtimes)', { error: error instanceof Error ? error.message : String(error) }); return false; } } @@ -49,8 +52,9 @@ function tryParseJson(input: string): { success: true; value: unknown } | { succ try { const value = JSON.parse(trimmed); return { success: true, value }; - } catch { - // JSON is incomplete or invalid + } catch (error) { + // JSON is incomplete or invalid — expected during incremental parsing + logger.debug('HOOK', 'JSON parse attempt incomplete', { error: error instanceof Error ? error.message : String(error) }); return { success: false }; } } @@ -128,47 +132,52 @@ export async function readJsonFromStdin(): Promise { } }, SAFETY_TIMEOUT_MS); + const onData = (chunk: Buffer | string) => { + input += chunk; + + // Clear any pending parse delay + if (parseDelayId) { + clearTimeout(parseDelayId); + parseDelayId = null; + } + + // Try to parse immediately - if JSON is complete, resolve now + if (tryResolveWithJson()) { + return; + } + + // If immediate parse failed, set a short delay and try again + // This handles multi-chunk delivery where the last chunk completes the JSON + parseDelayId = setTimeout(() => { + tryResolveWithJson(); + }, PARSE_DELAY_MS); + }; + + const onEnd = () => { + // stdin closed - parse whatever we have + if (!resolved) { + if (!tryResolveWithJson()) { + // Empty or invalid - resolve with undefined + resolveWith(input.trim() ? undefined : undefined); + } + } + }; + + const onError = () => { + if (!resolved) { + // Don't reject on stdin errors - just return undefined + // This is more graceful for hook execution + resolveWith(undefined); + } + }; + try { - process.stdin.on('data', (chunk) => { - input += chunk; - - // Clear any pending parse delay - if (parseDelayId) { - clearTimeout(parseDelayId); - parseDelayId = null; - } - - // Try to parse immediately - if JSON is complete, resolve now - if (tryResolveWithJson()) { - return; - } - - // If immediate parse failed, set a short delay and try again - // This handles multi-chunk delivery where the last chunk completes the JSON - parseDelayId = setTimeout(() => { - tryResolveWithJson(); - }, PARSE_DELAY_MS); - }); - - process.stdin.on('end', () => { - // stdin closed - parse whatever we have - if (!resolved) { - if (!tryResolveWithJson()) { - // Empty or invalid - resolve with undefined - resolveWith(input.trim() ? undefined : undefined); - } - } - }); - - process.stdin.on('error', () => { - if (!resolved) { - // Don't reject on stdin errors - just return undefined - // This is more graceful for hook execution - resolveWith(undefined); - } - }); - } catch { + process.stdin.on('data', onData); + process.stdin.on('end', onEnd); + process.stdin.on('error', onError); + } catch (error) { // If attaching listeners fails (Bun stdin issue), resolve with undefined + logger.debug('HOOK', 'Failed to attach stdin listeners', { error: error instanceof Error ? error.message : String(error) }); resolved = true; clearTimeout(safetyTimeoutId); cleanup(); diff --git a/src/integrations/opencode-plugin/index.ts b/src/integrations/opencode-plugin/index.ts index b9c40ed3..a0670046 100644 --- a/src/integrations/opencode-plugin/index.ts +++ b/src/integrations/opencode-plugin/index.ts @@ -105,17 +105,13 @@ async function workerPost( path: string, body: Record, ): Promise | null> { + let response: Response; try { - const response = await fetch(`${WORKER_BASE_URL}${path}`, { + response = await fetch(`${WORKER_BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!response.ok) { - console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); - return null; - } - return (await response.json()) as Record; } catch (error: unknown) { // Gracefully handle ECONNREFUSED — worker may not be running const message = error instanceof Error ? error.message : String(error); @@ -124,6 +120,12 @@ async function workerPost( } return null; } + + if (!response.ok) { + console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); + return null; + } + return (await response.json()) as Record; } function workerPostFireAndForget( @@ -339,24 +341,27 @@ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { return "claude-mem worker is not running. Start it with: npx claude-mem start"; } + let data: any; try { - const data = JSON.parse(text); - const items = Array.isArray(data.items) ? data.items : []; - if (items.length === 0) { - return `No results found for "${query}".`; - } - - return items - .slice(0, 10) - .map((item: Record, index: number) => { - const title = String(item.title || item.subtitle || "Untitled"); - const project = item.project ? ` [${String(item.project)}]` : ""; - return `${index + 1}. ${title}${project}`; - }) - .join("\n"); - } catch { + data = JSON.parse(text); + } catch (error: unknown) { + console.warn('[claude-mem] Failed to parse search results:', error instanceof Error ? error.message : String(error)); return "Failed to parse search results."; } + + const items = Array.isArray(data.items) ? data.items : []; + if (items.length === 0) { + return `No results found for "${query}".`; + } + + return items + .slice(0, 10) + .map((item: Record, index: number) => { + const title = String(item.title || item.subtitle || "Untitled"); + const project = item.project ? ` [${String(item.project)}]` : ""; + return `${index + 1}. ${title}${project}`; + }) + .join("\n"); }, } satisfies ToolDefinition, }, diff --git a/src/npx-cli/commands/ide-detection.ts b/src/npx-cli/commands/ide-detection.ts index df6b3a26..03a4e7ed 100644 --- a/src/npx-cli/commands/ide-detection.ts +++ b/src/npx-cli/commands/ide-detection.ts @@ -38,7 +38,11 @@ function isCommandInPath(command: string): boolean { const whichCommand = IS_WINDOWS ? 'where' : 'which'; execSync(`${whichCommand} ${command}`, { stdio: 'pipe' }); return true; - } catch { + } catch (error: unknown) { + // Command not found in PATH — expected for non-installed IDEs + if (process.env.DEBUG) { + console.error(`[ide-detection] ${command} not in PATH:`, error instanceof Error ? error.message : String(error)); + } return false; } } @@ -53,7 +57,8 @@ function hasVscodeExtension(extensionNameFragment: string): boolean { try { const entries = readdirSync(extensionsDirectory); return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase())); - } catch { + } catch (error: unknown) { + console.warn('[ide-detection] Failed to read VS Code extensions directory:', error instanceof Error ? error.message : String(error)); return false; } } diff --git a/src/npx-cli/commands/install.ts b/src/npx-cli/commands/install.ts index 0cf490ab..de02a2b9 100644 --- a/src/npx-cli/commands/install.ts +++ b/src/npx-cli/commands/install.ts @@ -128,7 +128,8 @@ async function setupIDEs(selectedIDEs: string[]): Promise { { stdio: 'inherit' }, ); log.success('Claude Code: plugin installed via CLI.'); - } catch { + } catch (error: unknown) { + console.error('[install] Claude Code plugin install error:', error instanceof Error ? error.message : String(error)); log.error('Claude Code: plugin install failed. Is `claude` CLI on your PATH?'); failedIDEs.push(ideId); } @@ -372,7 +373,8 @@ function runSmartInstall(): boolean { ...(IS_WINDOWS ? { shell: true as const } : {}), }); return true; - } catch { + } catch (error: unknown) { + console.warn('[install] smart-install error:', error instanceof Error ? error.message : String(error)); log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.'); return false; } @@ -409,7 +411,8 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise { const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777'; const searchUrl = `http://127.0.0.1:${workerPort}/api/search?query=${encodeURIComponent(query)}`; + let response: Response; try { - const response = await fetch(searchUrl); - - if (!response.ok) { - if (response.status === 404) { - console.error(pc.red('Search endpoint not found. Is the worker running?')); - console.error(`Try: ${pc.bold('npx claude-mem start')}`); - process.exit(1); - } - console.error(pc.red(`Search failed: HTTP ${response.status}`)); - process.exit(1); - } - - const data = await response.json(); - - if (typeof data === 'object' && data !== null) { - console.log(JSON.stringify(data, null, 2)); - } else { - console.log(data); - } - } catch (error: any) { - if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) { + response = await fetch(searchUrl); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error ? (error as any).cause : undefined; + if (cause?.code === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) { console.error(pc.red('Worker is not running.')); console.error(`Start it with: ${pc.bold('npx claude-mem start')}`); process.exit(1); } - console.error(pc.red(`Search failed: ${error.message}`)); + console.error(pc.red(`Search failed: ${message}`)); process.exit(1); } + + if (!response.ok) { + if (response.status === 404) { + console.error(pc.red('Search endpoint not found. Is the worker running?')); + console.error(`Try: ${pc.bold('npx claude-mem start')}`); + process.exit(1); + } + console.error(pc.red(`Search failed: HTTP ${response.status}`)); + process.exit(1); + } + + const data = await response.json(); + + if (typeof data === 'object' && data !== null) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(data); + } } /** diff --git a/src/npx-cli/commands/uninstall.ts b/src/npx-cli/commands/uninstall.ts index 86b0fa98..a70bf1de 100644 --- a/src/npx-cli/commands/uninstall.ts +++ b/src/npx-cli/commands/uninstall.ts @@ -120,8 +120,10 @@ export async function runUninstallCommand(): Promise { signal: AbortSignal.timeout(1000), }); // Still alive — keep waiting - } catch { - break; // Connection refused = worker is gone + } catch (error: unknown) { + // Connection refused = worker is gone (expected shutdown behavior) + console.error('[uninstall] Worker health check failed (worker stopped):', error instanceof Error ? error.message : String(error)); + break; } } p.log.info('Worker service stopped.'); @@ -201,8 +203,9 @@ export async function runUninstallCommand(): Promise { if (result === 0) { p.log.info(`${label}: removed.`); } - } catch { - // IDE not configured or uninstaller errored — skip silently + } catch (error: unknown) { + // IDE not configured or uninstaller errored — log and continue + console.warn(`[uninstall] ${label} cleanup failed:`, error instanceof Error ? error.message : String(error)); } } diff --git a/src/npx-cli/utils/bun-resolver.ts b/src/npx-cli/utils/bun-resolver.ts index a019b7bd..dbec473c 100644 --- a/src/npx-cli/utils/bun-resolver.ts +++ b/src/npx-cli/utils/bun-resolver.ts @@ -79,7 +79,8 @@ export function getBunVersionString(): string | null { shell: IS_WINDOWS, }); return result.status === 0 ? result.stdout.trim() : null; - } catch { + } catch (error: unknown) { + console.error('[bun-resolver] Failed to get Bun version:', error instanceof Error ? error.message : String(error)); return null; } } diff --git a/src/sdk/prompts.ts b/src/sdk/prompts.ts index c552d660..db9147a5 100644 --- a/src/sdk/prompts.ts +++ b/src/sdk/prompts.ts @@ -109,19 +109,19 @@ export function buildObservationPrompt(obs: Observation): string { try { toolInput = typeof obs.tool_input === 'string' ? JSON.parse(obs.tool_input) : obs.tool_input; - } catch (error) { + } catch (error: unknown) { logger.debug('SDK', 'Tool input is plain string, using as-is', { toolName: obs.tool_name - }, error as Error); + }, error instanceof Error ? error : new Error(String(error))); toolInput = obs.tool_input; } try { toolOutput = typeof obs.tool_output === 'string' ? JSON.parse(obs.tool_output) : obs.tool_output; - } catch (error) { + } catch (error: unknown) { logger.debug('SDK', 'Tool output is plain string, using as-is', { toolName: obs.tool_name - }, error as Error); + }, error instanceof Error ? error : new Error(String(error))); toolOutput = obs.tool_output; } diff --git a/src/servers/mcp-server.ts b/src/servers/mcp-server.ts index 164f95a5..a9dea744 100644 --- a/src/servers/mcp-server.ts +++ b/src/servers/mcp-server.ts @@ -108,17 +108,18 @@ async function callWorkerAPI( ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { logger.debug('SYSTEM', '→ Worker API', undefined, { endpoint, params }); - try { - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - // Convert params to query string - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } + // Convert params to query string + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); } + } - const apiPath = `${endpoint}?${searchParams}`; + const apiPath = `${endpoint}?${searchParams}`; + + try { const response = await workerHttpRequest(apiPath); if (!response.ok) { @@ -132,8 +133,8 @@ async function callWorkerAPI( // Worker returns { content: [...] } format directly return data; - } catch (error) { - logger.error('SYSTEM', '← Worker API error', { endpoint }, error as Error); + } catch (error: unknown) { + logger.error('SYSTEM', '← Worker API error', { endpoint }, error instanceof Error ? error : new Error(String(error))); return { content: [{ type: 'text' as const, @@ -144,6 +145,33 @@ async function callWorkerAPI( } } +async function executeWorkerPostRequest( + endpoint: string, + body: Record +): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const response = await workerHttpRequest(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Worker API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + + logger.debug('HTTP', 'Worker API success (POST)', undefined, { endpoint }); + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify(data, null, 2) + }] + }; +} + /** * Call Worker HTTP API with POST body */ @@ -154,30 +182,9 @@ async function callWorkerAPIPost( logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint }); try { - const response = await workerHttpRequest(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Worker API error (${response.status}): ${errorText}`); - } - - const data = await response.json(); - - logger.debug('HTTP', 'Worker API success (POST)', undefined, { endpoint }); - - // Wrap raw data in MCP format - return { - content: [{ - type: 'text' as const, - text: JSON.stringify(data, null, 2) - }] - }; - } catch (error) { - logger.error('HTTP', 'Worker API error (POST)', { endpoint }, error as Error); + return await executeWorkerPostRequest(endpoint, body); + } catch (error: unknown) { + logger.error('HTTP', 'Worker API error (POST)', { endpoint }, error instanceof Error ? error : new Error(String(error))); return { content: [{ type: 'text' as const, @@ -195,9 +202,9 @@ async function verifyWorkerConnection(): Promise { try { const response = await workerHttpRequest('/api/health'); return response.ok; - } catch (error) { + } catch (error: unknown) { // Expected during worker startup or if worker is down - logger.debug('SYSTEM', 'Worker health check failed', {}, error as Error); + logger.debug('SYSTEM', 'Worker health check failed', {}, error instanceof Error ? error : new Error(String(error))); return false; } } @@ -229,12 +236,12 @@ async function ensureWorkerConnection(): Promise { ); } return started; - } catch (error) { + } catch (error: unknown) { logger.error( 'SYSTEM', 'Worker auto-start threw — MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running.', undefined, - error as Error + error instanceof Error ? error : new Error(String(error)) ); return false; } @@ -593,8 +600,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { return await tool.handler(request.params.arguments || {}); - } catch (error) { - logger.error('SYSTEM', 'Tool execution failed', { tool: request.params.name }, error as Error); + } catch (error: unknown) { + logger.error('SYSTEM', 'Tool execution failed', { tool: request.params.name }, error instanceof Error ? error : new Error(String(error))); return { content: [{ type: 'text' as const, diff --git a/src/services/context/ContextBuilder.ts b/src/services/context/ContextBuilder.ts index b7f0aa79..a111f3ed 100644 --- a/src/services/context/ContextBuilder.ts +++ b/src/services/context/ContextBuilder.ts @@ -49,14 +49,18 @@ const VERSION_MARKER_PATH = path.join( function initializeDatabase(): SessionStore | null { try { return new SessionStore(); - } catch (error: any) { - if (error.code === 'ERR_DLOPEN_FAILED') { + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_DLOPEN_FAILED') { try { unlinkSync(VERSION_MARKER_PATH); } catch (unlinkError) { - logger.debug('SYSTEM', 'Marker file cleanup failed (may not exist)', {}, unlinkError as Error); + if (unlinkError instanceof Error) { + logger.debug('WORKER', 'Marker file cleanup failed (may not exist)', {}, unlinkError); + } else { + logger.debug('WORKER', 'Marker file cleanup failed (may not exist)', { error: String(unlinkError) }); + } } - logger.error('SYSTEM', 'Native module rebuild needed - restart Claude Code to auto-fix'); + logger.error('WORKER', 'Native module rebuild needed - restart Claude Code to auto-fix'); return null; } throw error; diff --git a/src/services/context/ObservationCompiler.ts b/src/services/context/ObservationCompiler.ts index 21c5b50f..d30b45e9 100644 --- a/src/services/context/ObservationCompiler.ts +++ b/src/services/context/ObservationCompiler.ts @@ -207,53 +207,59 @@ function cwdToDashed(cwd: string): string { return cwd.replace(/\//g, '-'); } +/** + * Find the last assistant message text from parsed transcript lines. + */ +function parseAssistantTextFromLine(line: string): string | null { + if (!line.includes('"type":"assistant"')) return null; + + const entry = JSON.parse(line); + if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) { + let text = ''; + for (const block of entry.message.content) { + if (block.type === 'text') text += block.text; + } + text = text.replace(SYSTEM_REMINDER_REGEX, '').trim(); + if (text) return text; + } + return null; +} + +function findLastAssistantMessage(lines: string[]): string { + for (let i = lines.length - 1; i >= 0; i--) { + try { + const result = parseAssistantTextFromLine(lines[i]); + if (result) return result; + } catch (parseError) { + if (parseError instanceof Error) { + logger.debug('WORKER', 'Skipping malformed transcript line', { lineIndex: i }, parseError); + } else { + logger.debug('WORKER', 'Skipping malformed transcript line', { lineIndex: i, error: String(parseError) }); + } + continue; + } + } + return ''; +} + /** * Extract prior messages from transcript file */ export function extractPriorMessages(transcriptPath: string): PriorMessages { try { - if (!existsSync(transcriptPath)) { - return { userMessage: '', assistantMessage: '' }; - } - + if (!existsSync(transcriptPath)) return { userMessage: '', assistantMessage: '' }; const content = readFileSync(transcriptPath, 'utf-8').trim(); - if (!content) { - return { userMessage: '', assistantMessage: '' }; - } + if (!content) return { userMessage: '', assistantMessage: '' }; const lines = content.split('\n').filter(line => line.trim()); - let lastAssistantMessage = ''; - - for (let i = lines.length - 1; i >= 0; i--) { - try { - const line = lines[i]; - if (!line.includes('"type":"assistant"')) { - continue; - } - - const entry = JSON.parse(line); - if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) { - let text = ''; - for (const block of entry.message.content) { - if (block.type === 'text') { - text += block.text; - } - } - text = text.replace(SYSTEM_REMINDER_REGEX, '').trim(); - if (text) { - lastAssistantMessage = text; - break; - } - } - } catch (parseError) { - logger.debug('PARSER', 'Skipping malformed transcript line', { lineIndex: i }, parseError as Error); - continue; - } - } - + const lastAssistantMessage = findLastAssistantMessage(lines); return { userMessage: '', assistantMessage: lastAssistantMessage }; } catch (error) { - logger.failure('WORKER', `Failed to extract prior messages from transcript`, { transcriptPath }, error as Error); + if (error instanceof Error) { + logger.failure('WORKER', 'Failed to extract prior messages from transcript', { transcriptPath }, error); + } else { + logger.warn('WORKER', 'Failed to extract prior messages from transcript', { transcriptPath, error: String(error) }); + } return { userMessage: '', assistantMessage: '' }; } } diff --git a/src/services/domain/ModeManager.ts b/src/services/domain/ModeManager.ts index 804af598..e19c426c 100644 --- a/src/services/domain/ModeManager.ts +++ b/src/services/domain/ModeManager.ts @@ -144,7 +144,11 @@ export class ModeManager { }); return mode; } catch (error) { - logger.warn('SYSTEM', `Mode file not found: ${modeId}, falling back to 'code'`); + if (error instanceof Error) { + logger.warn('WORKER', `Mode file not found: ${modeId}, falling back to 'code'`, { message: error.message }); + } else { + logger.warn('WORKER', `Mode file not found: ${modeId}, falling back to 'code'`, { error: String(error) }); + } // If we're already trying to load 'code', throw to prevent infinite recursion if (modeId === 'code') { throw new Error('Critical: code.json mode file missing'); @@ -161,7 +165,11 @@ export class ModeManager { try { parentMode = this.loadMode(parentId); } catch (error) { - logger.warn('SYSTEM', `Parent mode '${parentId}' not found for ${modeId}, falling back to 'code'`); + if (error instanceof Error) { + logger.warn('WORKER', `Parent mode '${parentId}' not found for ${modeId}, falling back to 'code'`, { message: error.message }); + } else { + logger.warn('WORKER', `Parent mode '${parentId}' not found for ${modeId}, falling back to 'code'`, { error: String(error) }); + } parentMode = this.loadMode('code'); } @@ -171,7 +179,11 @@ export class ModeManager { overrideConfig = this.loadModeFile(overrideId); logger.debug('SYSTEM', `Loaded override file: ${overrideId} for parent ${parentId}`); } catch (error) { - logger.warn('SYSTEM', `Override file '${overrideId}' not found, using parent mode '${parentId}' only`); + if (error instanceof Error) { + logger.warn('WORKER', `Override file '${overrideId}' not found, using parent mode '${parentId}' only`, { message: error.message }); + } else { + logger.warn('WORKER', `Override file '${overrideId}' not found, using parent mode '${parentId}' only`, { error: String(error) }); + } this.activeMode = parentMode; return parentMode; } diff --git a/src/services/infrastructure/HealthMonitor.ts b/src/services/infrastructure/HealthMonitor.ts index d981d67c..c3d16ebb 100644 --- a/src/services/infrastructure/HealthMonitor.ts +++ b/src/services/infrastructure/HealthMonitor.ts @@ -53,7 +53,12 @@ export async function isPortInUse(port: number): Promise { try { const response = await fetch(`http://127.0.0.1:${port}/api/health`); return response.ok; - } catch { + } catch (error) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Windows health check failed (port not in use)', {}, error); + } else { + logger.debug('SYSTEM', 'Windows health check failed (port not in use)', { error: String(error) }); + } return false; } } @@ -92,7 +97,11 @@ async function pollEndpointUntilOk( if (result.ok) return true; } catch (error) { // [ANTI-PATTERN IGNORED]: Retry loop - expected failures during startup, will retry - logger.debug('SYSTEM', retryLogMessage, {}, error as Error); + if (error instanceof Error) { + logger.debug('SYSTEM', retryLogMessage, {}, error); + } else { + logger.debug('SYSTEM', retryLogMessage, { error: String(error) }); + } } await new Promise(r => setTimeout(r, 500)); } @@ -166,10 +175,13 @@ export function getInstalledPluginVersion(): string { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); return packageJson.version; } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'EBUSY') { - logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code }); - return 'unknown'; + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'EBUSY') { + logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code }); + return 'unknown'; + } + throw error; } throw error; } diff --git a/src/services/infrastructure/ProcessManager.ts b/src/services/infrastructure/ProcessManager.ts index becbebb5..8b1a4698 100644 --- a/src/services/infrastructure/ProcessManager.ts +++ b/src/services/infrastructure/ProcessManager.ts @@ -53,22 +53,28 @@ function isBunExecutablePath(executablePath: string | undefined | null): boolean function lookupBinaryInPath(binaryName: string, platform: NodeJS.Platform): string | null { const command = platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`; + let output: string; try { - const output = execSync(command, { + output = execSync(command, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', windowsHide: true }); - - const firstMatch = output - .split(/\r?\n/) - .map(line => line.trim()) - .find(line => line.length > 0); - - return firstMatch || null; - } catch { + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', `Binary lookup failed for ${binaryName}`, { command }, error); + } else { + logger.debug('SYSTEM', `Binary lookup failed for ${binaryName}`, { command }, new Error(String(error))); + } return null; } + + const firstMatch = output + .split(/\r?\n/) + .map(line => line.trim()) + .find(line => line.length > 0); + + return firstMatch || null; } // Memoize the resolved runtime path for the no-options call site (which is @@ -202,8 +208,12 @@ export function readPidFile(): PidInfo | null { try { return JSON.parse(readFileSync(PID_FILE, 'utf-8')); - } catch (error) { - logger.warn('SYSTEM', 'Failed to parse PID file', { path: PID_FILE }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.warn('SYSTEM', 'Failed to parse PID file', { path: PID_FILE }, error); + } else { + logger.warn('SYSTEM', 'Failed to parse PID file', { path: PID_FILE }, new Error(String(error))); + } return null; } } @@ -216,9 +226,13 @@ export function removePidFile(): void { try { unlinkSync(PID_FILE); - } catch (error) { + } catch (error: unknown) { // [ANTI-PATTERN IGNORED]: Cleanup function - PID file removal failure is non-critical - logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE }, error as Error); + if (error instanceof Error) { + logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE }, error); + } else { + logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE }, new Error(String(error))); + } } } @@ -260,9 +274,13 @@ export async function getChildProcesses(parentPid: number): Promise { .filter(line => line.length > 0 && /^\d+$/.test(line)) .map(line => parseInt(line, 10)) .filter(pid => pid > 0); - } catch (error) { + } catch (error: unknown) { // Shutdown cleanup - failure is non-critical, continue without child process cleanup - logger.error('SYSTEM', 'Failed to enumerate child processes', { parentPid }, error as Error); + if (error instanceof Error) { + logger.error('SYSTEM', 'Failed to enumerate child processes', { parentPid }, error); + } else { + logger.error('SYSTEM', 'Failed to enumerate child processes', { parentPid }, new Error(String(error))); + } return []; } } @@ -287,9 +305,13 @@ export async function forceKillProcess(pid: number): Promise { process.kill(pid, 'SIGKILL'); } logger.info('SYSTEM', 'Killed process', { pid }); - } catch (error) { + } catch (error: unknown) { // [ANTI-PATTERN IGNORED]: Shutdown cleanup - process already exited, continue - logger.debug('SYSTEM', 'Process already exited during force kill', { pid }, error as Error); + if (error instanceof Error) { + logger.debug('SYSTEM', 'Process already exited during force kill', { pid }, error); + } else { + logger.debug('SYSTEM', 'Process already exited during force kill', { pid }, new Error(String(error))); + } } } @@ -304,8 +326,11 @@ export async function waitForProcessesExit(pids: number[], timeoutMs: number): P try { process.kill(pid, 0); return true; - } catch (error) { - // [ANTI-PATTERN IGNORED]: Tight loop checking 100s of PIDs every 100ms during cleanup + } catch (error: unknown) { + // process.kill(pid, 0) throws when PID doesn't exist — expected during cleanup + if (error instanceof Error) { + logger.debug('SYSTEM', `Process ${pid} no longer exists`, { pid, error: error.message }); + } return false; } }); @@ -357,6 +382,84 @@ export function parseElapsedTime(etime: string): number { return -1; } +/** + * Enumerate orphaned claude-mem processes matching ORPHAN_PROCESS_PATTERNS. + * Returns PIDs of processes older than ORPHAN_MAX_AGE_MINUTES. + */ +async function enumerateOrphanedProcesses(isWindows: boolean, currentPid: number): Promise { + const pidsToKill: number[] = []; + + if (isWindows) { + // Windows: Use WQL -Filter for server-side filtering (no $_ pipeline syntax). + // Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024). + const wqlPatternConditions = ORPHAN_PROCESS_PATTERNS + .map(p => `CommandLine LIKE '%${p}%'`) + .join(' OR '); + + const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CreationDate | ConvertTo-Json"`; + const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true }); + + if (!stdout.trim() || stdout.trim() === 'null') { + logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)'); + return []; + } + + const processes = JSON.parse(stdout); + const processList = Array.isArray(processes) ? processes : [processes]; + const now = Date.now(); + + for (const proc of processList) { + const pid = proc.ProcessId; + // SECURITY: Validate PID is positive integer and not current process + if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue; + + // Parse Windows WMI date format: /Date(1234567890123)/ + const creationMatch = proc.CreationDate?.match(/\/Date\((\d+)\)\//); + if (creationMatch) { + const creationTime = parseInt(creationMatch[1], 10); + const ageMinutes = (now - creationTime) / (1000 * 60); + + if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes: Math.round(ageMinutes) }); + } + } + } + } else { + // Unix: Use ps with elapsed time for age-based filtering + const patternRegex = ORPHAN_PROCESS_PATTERNS.join('|'); + const { stdout } = await execAsync( + `ps -eo pid,etime,command | grep -E "${patternRegex}" | grep -v grep || true` + ); + + if (!stdout.trim()) { + logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Unix)'); + return []; + } + + const lines = stdout.trim().split('\n'); + for (const line of lines) { + // Parse: " 1234 01:23:45 /path/to/process" + const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/); + if (!match) continue; + + const pid = parseInt(match[1], 10); + const etime = match[2]; + + // SECURITY: Validate PID is positive integer and not current process + if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue; + + const ageMinutes = parseElapsedTime(etime); + if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes, command: match[3].substring(0, 80) }); + } + } + } + + return pidsToKill; +} + /** * Clean up orphaned claude-mem processes from previous worker sessions * @@ -370,79 +473,17 @@ export function parseElapsedTime(etime: string): number { export async function cleanupOrphanedProcesses(): Promise { const isWindows = process.platform === 'win32'; const currentPid = process.pid; - const pidsToKill: number[] = []; + let pidsToKill: number[]; try { - if (isWindows) { - // Windows: Use WQL -Filter for server-side filtering (no $_ pipeline syntax). - // Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024). - const wqlPatternConditions = ORPHAN_PROCESS_PATTERNS - .map(p => `CommandLine LIKE '%${p}%'`) - .join(' OR '); - - const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CreationDate | ConvertTo-Json"`; - const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true }); - - if (!stdout.trim() || stdout.trim() === 'null') { - logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)'); - return; - } - - const processes = JSON.parse(stdout); - const processList = Array.isArray(processes) ? processes : [processes]; - const now = Date.now(); - - for (const proc of processList) { - const pid = proc.ProcessId; - // SECURITY: Validate PID is positive integer and not current process - if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue; - - // Parse Windows WMI date format: /Date(1234567890123)/ - const creationMatch = proc.CreationDate?.match(/\/Date\((\d+)\)\//); - if (creationMatch) { - const creationTime = parseInt(creationMatch[1], 10); - const ageMinutes = (now - creationTime) / (1000 * 60); - - if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes: Math.round(ageMinutes) }); - } - } - } - } else { - // Unix: Use ps with elapsed time for age-based filtering - const patternRegex = ORPHAN_PROCESS_PATTERNS.join('|'); - const { stdout } = await execAsync( - `ps -eo pid,etime,command | grep -E "${patternRegex}" | grep -v grep || true` - ); - - if (!stdout.trim()) { - logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Unix)'); - return; - } - - const lines = stdout.trim().split('\n'); - for (const line of lines) { - // Parse: " 1234 01:23:45 /path/to/process" - const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/); - if (!match) continue; - - const pid = parseInt(match[1], 10); - const etime = match[2]; - - // SECURITY: Validate PID is positive integer and not current process - if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue; - - const ageMinutes = parseElapsedTime(etime); - if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes, command: match[3].substring(0, 80) }); - } - } - } - } catch (error) { + pidsToKill = await enumerateOrphanedProcesses(isWindows, currentPid); + } catch (error: unknown) { // Orphan cleanup is non-critical - log and continue - logger.error('SYSTEM', 'Failed to enumerate orphaned processes', {}, error as Error); + if (error instanceof Error) { + logger.error('SYSTEM', 'Failed to enumerate orphaned processes', {}, error); + } else { + logger.error('SYSTEM', 'Failed to enumerate orphaned processes', {}, new Error(String(error))); + } return; } @@ -467,18 +508,26 @@ export async function cleanupOrphanedProcesses(): Promise { } try { execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore', windowsHide: true }); - } catch (error) { + } catch (error: unknown) { // [ANTI-PATTERN IGNORED]: Cleanup loop - process may have exited, continue to next PID - logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error as Error); + if (error instanceof Error) { + logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error); + } else { + logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, new Error(String(error))); + } } } } else { for (const pid of pidsToKill) { try { process.kill(pid, 'SIGKILL'); - } catch (error) { + } catch (error: unknown) { // [ANTI-PATTERN IGNORED]: Cleanup loop - process may have exited, continue to next PID - logger.debug('SYSTEM', 'Process already exited', { pid }, error as Error); + if (error instanceof Error) { + logger.debug('SYSTEM', 'Process already exited', { pid }, error); + } else { + logger.debug('SYSTEM', 'Process already exited', { pid }, new Error(String(error))); + } } } } @@ -493,6 +542,104 @@ const AGGRESSIVE_CLEANUP_PATTERNS = ['worker-service.cjs', 'chroma-mcp']; // Patterns that keep the age-gated threshold (may be legitimately running) const AGE_GATED_CLEANUP_PATTERNS = ['mcp-server.cjs']; +/** + * Enumerate processes for aggressive startup cleanup. Aggressive patterns are + * killed immediately; age-gated patterns only if older than ORPHAN_MAX_AGE_MINUTES. + */ +async function enumerateAggressiveCleanupProcesses( + isWindows: boolean, + currentPid: number, + protectedPids: Set, + allPatterns: string[] +): Promise { + const pidsToKill: number[] = []; + + if (isWindows) { + // Use WQL -Filter for server-side filtering (no $_ pipeline syntax). + // Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024). + const wqlPatternConditions = allPatterns + .map(p => `CommandLine LIKE '%${p}%'`) + .join(' OR '); + + const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CommandLine, CreationDate | ConvertTo-Json"`; + const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true }); + + if (!stdout.trim() || stdout.trim() === 'null') { + logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)'); + return []; + } + + const processes = JSON.parse(stdout); + const processList = Array.isArray(processes) ? processes : [processes]; + const now = Date.now(); + + for (const proc of processList) { + const pid = proc.ProcessId; + if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue; + + const commandLine = proc.CommandLine || ''; + const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => commandLine.includes(p)); + + if (isAggressive) { + // Kill immediately — no age check + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process (aggressive)', { pid, commandLine: commandLine.substring(0, 80) }); + } else { + // Age-gated: only kill if older than threshold + const creationMatch = proc.CreationDate?.match(/\/Date\((\d+)\)\//); + if (creationMatch) { + const creationTime = parseInt(creationMatch[1], 10); + const ageMinutes = (now - creationTime) / (1000 * 60); + if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process (age-gated)', { pid, ageMinutes: Math.round(ageMinutes) }); + } + } + } + } + } else { + // Unix: Use ps with elapsed time + const patternRegex = allPatterns.join('|'); + const { stdout } = await execAsync( + `ps -eo pid,etime,command | grep -E "${patternRegex}" | grep -v grep || true` + ); + + if (!stdout.trim()) { + logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Unix)'); + return []; + } + + const lines = stdout.trim().split('\n'); + for (const line of lines) { + const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/); + if (!match) continue; + + const pid = parseInt(match[1], 10); + const etime = match[2]; + const command = match[3]; + + if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue; + + const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => command.includes(p)); + + if (isAggressive) { + // Kill immediately — no age check + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process (aggressive)', { pid, command: command.substring(0, 80) }); + } else { + // Age-gated: only kill if older than threshold + const ageMinutes = parseElapsedTime(etime); + if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { + pidsToKill.push(pid); + logger.debug('SYSTEM', 'Found orphaned process (age-gated)', { pid, ageMinutes, command: command.substring(0, 80) }); + } + } + } + } + + return pidsToKill; +} + /** * Aggressive startup cleanup for orphaned claude-mem processes. * @@ -506,7 +653,6 @@ const AGE_GATED_CLEANUP_PATTERNS = ['mcp-server.cjs']; export async function aggressiveStartupCleanup(): Promise { const isWindows = process.platform === 'win32'; const currentPid = process.pid; - const pidsToKill: number[] = []; const allPatterns = [...AGGRESSIVE_CLEANUP_PATTERNS, ...AGE_GATED_CLEANUP_PATTERNS]; // Protect parent process (the hook that spawned us) from being killed. @@ -522,91 +668,15 @@ export async function aggressiveStartupCleanup(): Promise { protectedPids.add(process.ppid); } + let pidsToKill: number[]; try { - if (isWindows) { - // Use WQL -Filter for server-side filtering (no $_ pipeline syntax). - // Avoids Git Bash $_ interpretation (#1062) and PowerShell syntax errors (#1024). - const wqlPatternConditions = allPatterns - .map(p => `CommandLine LIKE '%${p}%'`) - .join(' OR '); - - const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process -Filter '(${wqlPatternConditions}) AND ProcessId != ${currentPid}' | Select-Object ProcessId, CommandLine, CreationDate | ConvertTo-Json"`; - const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, windowsHide: true }); - - if (!stdout.trim() || stdout.trim() === 'null') { - logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)'); - return; - } - - const processes = JSON.parse(stdout); - const processList = Array.isArray(processes) ? processes : [processes]; - const now = Date.now(); - - for (const proc of processList) { - const pid = proc.ProcessId; - if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue; - - const commandLine = proc.CommandLine || ''; - const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => commandLine.includes(p)); - - if (isAggressive) { - // Kill immediately — no age check - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process (aggressive)', { pid, commandLine: commandLine.substring(0, 80) }); - } else { - // Age-gated: only kill if older than threshold - const creationMatch = proc.CreationDate?.match(/\/Date\((\d+)\)\//); - if (creationMatch) { - const creationTime = parseInt(creationMatch[1], 10); - const ageMinutes = (now - creationTime) / (1000 * 60); - if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process (age-gated)', { pid, ageMinutes: Math.round(ageMinutes) }); - } - } - } - } + pidsToKill = await enumerateAggressiveCleanupProcesses(isWindows, currentPid, protectedPids, allPatterns); + } catch (error: unknown) { + if (error instanceof Error) { + logger.error('SYSTEM', 'Failed to enumerate orphaned processes during aggressive cleanup', {}, error); } else { - // Unix: Use ps with elapsed time - const patternRegex = allPatterns.join('|'); - const { stdout } = await execAsync( - `ps -eo pid,etime,command | grep -E "${patternRegex}" | grep -v grep || true` - ); - - if (!stdout.trim()) { - logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Unix)'); - return; - } - - const lines = stdout.trim().split('\n'); - for (const line of lines) { - const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/); - if (!match) continue; - - const pid = parseInt(match[1], 10); - const etime = match[2]; - const command = match[3]; - - if (!Number.isInteger(pid) || pid <= 0 || protectedPids.has(pid)) continue; - - const isAggressive = AGGRESSIVE_CLEANUP_PATTERNS.some(p => command.includes(p)); - - if (isAggressive) { - // Kill immediately — no age check - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process (aggressive)', { pid, command: command.substring(0, 80) }); - } else { - // Age-gated: only kill if older than threshold - const ageMinutes = parseElapsedTime(etime); - if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) { - pidsToKill.push(pid); - logger.debug('SYSTEM', 'Found orphaned process (age-gated)', { pid, ageMinutes, command: command.substring(0, 80) }); - } - } - } + logger.error('SYSTEM', 'Failed to enumerate orphaned processes during aggressive cleanup', {}, new Error(String(error))); } - } catch (error) { - logger.error('SYSTEM', 'Failed to enumerate orphaned processes during aggressive cleanup', {}, error as Error); return; } @@ -625,16 +695,24 @@ export async function aggressiveStartupCleanup(): Promise { if (!Number.isInteger(pid) || pid <= 0) continue; try { execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore', windowsHide: true }); - } catch (error) { - logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error); + } else { + logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, new Error(String(error))); + } } } } else { for (const pid of pidsToKill) { try { process.kill(pid, 'SIGKILL'); - } catch (error) { - logger.debug('SYSTEM', 'Process already exited', { pid }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Process already exited', { pid }, error); + } else { + logger.debug('SYSTEM', 'Process already exited', { pid }, new Error(String(error))); + } } } } @@ -747,29 +825,43 @@ export function runOneTimeCwdRemap(dataDirectory?: string): void { logger.warn('SYSTEM', 'Running one-time cwd-based project remap', { dbPath }); - let db: import('bun:sqlite').Database | null = null; try { - const { Database } = require('bun:sqlite') as typeof import('bun:sqlite'); - - const probe = new Database(dbPath, { readonly: true }); - const hasPending = probe.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'" - ).get() as { name: string } | undefined; - probe.close(); - - if (!hasPending) { - mkdirSync(effectiveDataDir, { recursive: true }); - writeFileSync(markerPath, new Date().toISOString()); - logger.info('SYSTEM', 'pending_messages table not present, cwd-remap skipped'); - return; + executeCwdRemap(dbPath, effectiveDataDir, markerPath); + } catch (err: unknown) { + if (err instanceof Error) { + logger.error('SYSTEM', 'cwd-remap failed, marker not written (will retry on next startup)', {}, err); + } else { + logger.error('SYSTEM', 'cwd-remap failed, marker not written (will retry on next startup)', {}, new Error(String(err))); } + } +} - const backup = `${dbPath}.bak-cwd-remap-${Date.now()}`; - copyFileSync(dbPath, backup); - logger.info('SYSTEM', 'DB backed up before cwd-remap', { backup }); +/** + * Execute the cwd-remap DB migration. Extracted to keep the try block small. + * Opens, queries, and updates the DB, then writes the marker file on success. + */ +function executeCwdRemap(dbPath: string, effectiveDataDir: string, markerPath: string): void { + const { Database } = require('bun:sqlite') as typeof import('bun:sqlite'); - db = new Database(dbPath); + const probe = new Database(dbPath, { readonly: true }); + const hasPending = probe.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='pending_messages'" + ).get() as { name: string } | undefined; + probe.close(); + if (!hasPending) { + mkdirSync(effectiveDataDir, { recursive: true }); + writeFileSync(markerPath, new Date().toISOString()); + logger.info('SYSTEM', 'pending_messages table not present, cwd-remap skipped'); + return; + } + + const backup = `${dbPath}.bak-cwd-remap-${Date.now()}`; + copyFileSync(dbPath, backup); + logger.info('SYSTEM', 'DB backed up before cwd-remap', { backup }); + + const db = new Database(dbPath); + try { const cwdRows = db.prepare(` SELECT cwd FROM pending_messages WHERE cwd IS NOT NULL AND cwd != '' @@ -825,10 +917,8 @@ export function runOneTimeCwdRemap(dataDirectory?: string): void { mkdirSync(effectiveDataDir, { recursive: true }); writeFileSync(markerPath, new Date().toISOString()); logger.info('SYSTEM', 'cwd-remap marker written', { markerPath }); - } catch (err) { - logger.error('SYSTEM', 'cwd-remap failed, marker not written (will retry on next startup)', {}, err as Error); } finally { - db?.close(); + db.close(); } } @@ -896,9 +986,13 @@ export function spawnDaemon( // never falsy checks like `if (!pid)`, which would silently treat // success as failure here. return 0; - } catch (error) { + } catch (error: unknown) { // APPROVED OVERRIDE: Windows daemon spawn is best-effort; log and let callers fall back to health checks/retry flow. - logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error as Error); + if (error instanceof Error) { + logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, error); + } else { + logger.error('SYSTEM', 'Failed to spawn worker daemon on Windows', { runtimePath }, new Error(String(error))); + } return undefined; } } @@ -961,9 +1055,14 @@ export function isProcessAlive(pid: number): boolean { process.kill(pid, 0); return true; } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - // EPERM = process exists but different user/session — treat as alive - if (code === 'EPERM') return true; + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + // EPERM = process exists but different user/session — treat as alive + if (code === 'EPERM') return true; + logger.debug('SYSTEM', 'Process not alive', { pid, code }); + } else { + logger.debug('SYSTEM', 'Process not alive (non-Error thrown)', { pid }, new Error(String(error))); + } // ESRCH = no such process — it's dead return false; } @@ -983,7 +1082,12 @@ export function isPidFileRecent(thresholdMs: number = 15000): boolean { try { const stats = statSync(PID_FILE); return (Date.now() - stats.mtimeMs) < thresholdMs; - } catch { + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'PID file not accessible for recency check', { path: PID_FILE }, error); + } else { + logger.debug('SYSTEM', 'PID file not accessible for recency check', { path: PID_FILE }, new Error(String(error))); + } return false; } } @@ -1032,9 +1136,13 @@ export function createSignalHandler( try { await shutdownFn(); process.exit(0); - } catch (error) { + } catch (error: unknown) { // Top-level signal handler - log any shutdown error and exit - logger.error('SYSTEM', 'Error during shutdown', {}, error as Error); + if (error instanceof Error) { + logger.error('SYSTEM', 'Error during shutdown', {}, error); + } else { + logger.error('SYSTEM', 'Error during shutdown', {}, new Error(String(error))); + } // Exit gracefully: Windows Terminal won't keep tab open on exit 0 // Even on shutdown errors, exit cleanly to prevent tab accumulation process.exit(0); diff --git a/src/services/infrastructure/WorktreeAdoption.ts b/src/services/infrastructure/WorktreeAdoption.ts index a4a47c1c..b6d82939 100644 --- a/src/services/infrastructure/WorktreeAdoption.ts +++ b/src/services/infrastructure/WorktreeAdoption.ts @@ -248,22 +248,24 @@ export async function adoptMergedWorktrees(opts: { 'UPDATE session_summaries SET merged_into_project = ? WHERE project = ? AND merged_into_project IS NULL' ); + const adoptWorktreeInTransaction = (wt: WorktreeEntry) => { + const worktreeProject = getProjectContext(wt.path).primary; + const rows = selectObsForPatch.all( + worktreeProject, + parentProject + ) as Array<{ id: number }>; + for (const r of rows) adoptedSqliteIds.push(r.id); + + const obsChanges = updateObs.run(parentProject, worktreeProject).changes; + const sumChanges = updateSum.run(parentProject, worktreeProject).changes; + result.adoptedObservations += obsChanges; + result.adoptedSummaries += sumChanges; + }; + const tx = db.transaction(() => { for (const wt of targets) { try { - const worktreeProject = getProjectContext(wt.path).primary; - const rows = selectObsForPatch.all( - worktreeProject, - parentProject - ) as Array<{ id: number }>; - for (const r of rows) adoptedSqliteIds.push(r.id); - - // updateObs/updateSum only touch WHERE merged_into_project IS NULL, - // so .changes reflects only newly-adopted rows (not the re-patched ones). - const obsChanges = updateObs.run(parentProject, worktreeProject).changes; - const sumChanges = updateSum.run(parentProject, worktreeProject).changes; - result.adoptedObservations += obsChanges; - result.adoptedSummaries += sumChanges; + adoptWorktreeInTransaction(wt); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.warn('SYSTEM', 'Worktree adoption skipped branch', { @@ -285,7 +287,11 @@ export async function adoptMergedWorktrees(opts: { } catch (err) { if (err instanceof DryRunRollback) { // Rolled back as intended for dry-run — counts are still useful. + } else if (err instanceof Error) { + logger.error('SYSTEM', 'Worktree adoption transaction failed', {}, err); + throw err; } else { + logger.error('SYSTEM', 'Worktree adoption transaction failed with non-Error', { error: String(err) }); throw err; } } @@ -299,12 +305,20 @@ export async function adoptMergedWorktrees(opts: { await chromaSync.updateMergedIntoProject(adoptedSqliteIds, parentProject); result.chromaUpdates = adoptedSqliteIds.length; } catch (err) { - logger.error( - 'CHROMA_SYNC', - 'Worktree adoption Chroma patch failed (SQL already committed)', - { parentProject, sqliteIdCount: adoptedSqliteIds.length }, - err as Error - ); + if (err instanceof Error) { + logger.error( + 'SYSTEM', + 'Worktree adoption Chroma patch failed (SQL already committed)', + { parentProject, sqliteIdCount: adoptedSqliteIds.length }, + err + ); + } else { + logger.error( + 'SYSTEM', + 'Worktree adoption Chroma patch failed (SQL already committed)', + { parentProject, sqliteIdCount: adoptedSqliteIds.length, error: String(err) } + ); + } result.chromaFailed = adoptedSqliteIds.length; } finally { await chromaSync.close(); diff --git a/src/services/integrations/CodexCliInstaller.ts b/src/services/integrations/CodexCliInstaller.ts index e68168bd..7d26d99c 100644 --- a/src/services/integrations/CodexCliInstaller.ts +++ b/src/services/integrations/CodexCliInstaller.ts @@ -67,7 +67,11 @@ function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig { return parsed; } catch (parseError) { - logger.error('SYSTEM', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error); + if (parseError instanceof Error) { + logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError); + } else { + logger.error('WORKER', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, new Error(String(parseError))); + } // Back up corrupt file const backupPath = `${configPath}.backup.${Date.now()}`; @@ -135,34 +139,40 @@ function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void { * Preserves any existing user content outside the tags. */ function removeCodexAgentsMdContext(): void { + if (!existsSync(CODEX_AGENTS_MD_PATH)) return; + + const startTag = ''; + const endTag = ''; + try { - if (!existsSync(CODEX_AGENTS_MD_PATH)) return; - - const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8'); - const startTag = ''; - const endTag = ''; - - const startIdx = content.indexOf(startTag); - const endIdx = content.indexOf(endTag); - - if (startIdx === -1 || endIdx === -1) return; - - const before = content.substring(0, startIdx).replace(/\n+$/, ''); - const after = content.substring(endIdx + endTag.length).replace(/^\n+/, ''); - const finalContent = (before + (after ? '\n\n' + after : '')).trim(); - - if (finalContent) { - writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n'); - } else { - writeFileSync(CODEX_AGENTS_MD_PATH, ''); - } - - console.log(` Removed legacy global context from ${CODEX_AGENTS_MD_PATH}`); + readAndStripContextTags(startTag, endTag); } catch (error) { - logger.warn('SYSTEM', 'Failed to clean AGENTS.md context', { error: (error as Error).message }); + const message = error instanceof Error ? error.message : String(error); + logger.warn('WORKER', 'Failed to clean AGENTS.md context', { error: message }); } } +function readAndStripContextTags(startTag: string, endTag: string): void { + const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8'); + + const startIdx = content.indexOf(startTag); + const endIdx = content.indexOf(endTag); + + if (startIdx === -1 || endIdx === -1) return; + + const before = content.substring(0, startIdx).replace(/\n+$/, ''); + const after = content.substring(endIdx + endTag.length).replace(/^\n+/, ''); + const finalContent = (before + (after ? '\n\n' + after : '')).trim(); + + if (finalContent) { + writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n'); + } else { + writeFileSync(CODEX_AGENTS_MD_PATH, ''); + } + + console.log(` Removed legacy global context from ${CODEX_AGENTS_MD_PATH}`); +} + /** * @deprecated Codex now uses workspace-local AGENTS.md via transcript processor fallback. * Preserves user content outside the tags. @@ -184,19 +194,29 @@ const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext; export async function installCodexCli(): Promise { console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n'); + // Step 1: Merge transcript-watch config + const existingConfig = loadExistingTranscriptWatchConfig(); + const mergedConfig = mergeCodexWatchConfig(existingConfig); + try { - // Step 1: Merge transcript-watch config - const existingConfig = loadExistingTranscriptWatchConfig(); - const mergedConfig = mergeCodexWatchConfig(existingConfig); - writeTranscriptWatchConfig(mergedConfig); - console.log(` Updated ${DEFAULT_CONFIG_PATH}`); - console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`); - console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`); + writeConfigAndShowCodexInstructions(mergedConfig); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nInstallation failed: ${message}`); + return 1; + } +} - // Step 2: Clean up legacy global AGENTS.md context - cleanupLegacyCodexAgentsMdContext(); +function writeConfigAndShowCodexInstructions(mergedConfig: TranscriptWatchConfig): void { + writeTranscriptWatchConfig(mergedConfig); + console.log(` Updated ${DEFAULT_CONFIG_PATH}`); + console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`); + console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`); - console.log(` + cleanupLegacyCodexAgentsMdContext(); + + console.log(` Installation complete! Transcript watch config: ${DEFAULT_CONFIG_PATH} @@ -211,12 +231,6 @@ Next steps: 1. Start claude-mem worker: npx claude-mem start 2. Use Codex CLI as usual -- memory capture is automatic! `); - - return 0; - } catch (error) { - console.error(`\nInstallation failed: ${(error as Error).message}`); - return 1; - } } // --------------------------------------------------------------------------- @@ -234,38 +248,37 @@ Next steps: export function uninstallCodexCli(): number { console.log('\nUninstalling Claude-Mem Codex CLI integration...\n'); - try { - // Step 1: Remove codex watch from transcript-watch.json - if (existsSync(DEFAULT_CONFIG_PATH)) { - const config = loadExistingTranscriptWatchConfig(); + // Step 1: Remove codex watch from transcript-watch.json + if (existsSync(DEFAULT_CONFIG_PATH)) { + const config = loadExistingTranscriptWatchConfig(); - // Remove codex watch - config.watches = config.watches.filter( - (w: WatchTarget) => w.name !== CODEX_WATCH_NAME, - ); + config.watches = config.watches.filter( + (w: WatchTarget) => w.name !== CODEX_WATCH_NAME, + ); - // Remove codex schema - if (config.schemas) { - delete config.schemas[CODEX_WATCH_NAME]; - } - - writeTranscriptWatchConfig(config); - console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`); - } else { - console.log(' No transcript-watch.json found -- nothing to remove.'); + if (config.schemas) { + delete config.schemas[CODEX_WATCH_NAME]; } - // Step 2: Remove legacy global context section from AGENTS.md - cleanupLegacyCodexAgentsMdContext(); - - console.log('\nUninstallation complete!'); - console.log('Restart claude-mem worker to apply changes.\n'); - - return 0; - } catch (error) { - console.error(`\nUninstallation failed: ${(error as Error).message}`); - return 1; + try { + writeTranscriptWatchConfig(config); + console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nUninstallation failed: ${message}`); + return 1; + } + } else { + console.log(' No transcript-watch.json found -- nothing to remove.'); } + + // Step 2: Remove legacy global context section from AGENTS.md + cleanupLegacyCodexAgentsMdContext(); + + console.log('\nUninstallation complete!'); + console.log('Restart claude-mem worker to apply changes.\n'); + + return 0; } // --------------------------------------------------------------------------- @@ -288,55 +301,61 @@ export function checkCodexCliStatus(): number { return 0; } + let config: TranscriptWatchConfig; try { - const config = loadExistingTranscriptWatchConfig(); - const codexWatch = config.watches.find( - (w: WatchTarget) => w.name === CODEX_WATCH_NAME, - ); - const codexSchema = config.schemas?.[CODEX_WATCH_NAME]; - - if (!codexWatch) { - console.log('Status: Not installed'); - console.log(' transcript-watch.json exists but no codex watch configured.'); - console.log('\nRun: npx claude-mem install --ide codex-cli\n'); - return 0; - } - - console.log('Status: Installed'); - console.log(` Config: ${DEFAULT_CONFIG_PATH}`); - console.log(` Watch path: ${codexWatch.path}`); - console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`); - console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`); - - // Check context config - if (codexWatch.context) { - console.log(` Context mode: ${codexWatch.context.mode}`); - console.log(` Context path: ${codexWatch.context.path ?? '/AGENTS.md (default)'}`); - console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`); - } - - // Check legacy global AGENTS.md usage - if (existsSync(CODEX_AGENTS_MD_PATH)) { - const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8'); - if (mdContent.includes('')) { - console.log(` Legacy global context: Present (${CODEX_AGENTS_MD_PATH})`); - } else { - console.log(` Legacy global context: Not active`); - } + config = loadExistingTranscriptWatchConfig(); + } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Could not parse transcript-watch.json', { path: DEFAULT_CONFIG_PATH }, error); } else { - console.log(` Legacy global context: None`); + logger.error('WORKER', 'Could not parse transcript-watch.json', { path: DEFAULT_CONFIG_PATH }, new Error(String(error))); } - - // Check if ~/.codex/sessions exists (indicates Codex has been used) - const sessionsDir = path.join(CODEX_DIR, 'sessions'); - if (existsSync(sessionsDir)) { - console.log(` Sessions directory: exists`); - } else { - console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`); - } - } catch { console.log('Status: Unknown'); console.log(' Could not parse transcript-watch.json.'); + console.log(''); + return 0; + } + + const codexWatch = config.watches.find( + (w: WatchTarget) => w.name === CODEX_WATCH_NAME, + ); + const codexSchema = config.schemas?.[CODEX_WATCH_NAME]; + + if (!codexWatch) { + console.log('Status: Not installed'); + console.log(' transcript-watch.json exists but no codex watch configured.'); + console.log('\nRun: npx claude-mem install --ide codex-cli\n'); + return 0; + } + + console.log('Status: Installed'); + console.log(` Config: ${DEFAULT_CONFIG_PATH}`); + console.log(` Watch path: ${codexWatch.path}`); + console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`); + console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`); + + if (codexWatch.context) { + console.log(` Context mode: ${codexWatch.context.mode}`); + console.log(` Context path: ${codexWatch.context.path ?? '/AGENTS.md (default)'}`); + console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`); + } + + if (existsSync(CODEX_AGENTS_MD_PATH)) { + const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8'); + if (mdContent.includes('')) { + console.log(` Legacy global context: Present (${CODEX_AGENTS_MD_PATH})`); + } else { + console.log(` Legacy global context: Not active`); + } + } else { + console.log(` Legacy global context: None`); + } + + const sessionsDir = path.join(CODEX_DIR, 'sessions'); + if (existsSync(sessionsDir)) { + console.log(` Sessions directory: exists`); + } else { + console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`); } console.log(''); diff --git a/src/services/integrations/CursorHooksInstaller.ts b/src/services/integrations/CursorHooksInstaller.ts index 60b32fbe..62f8b78e 100644 --- a/src/services/integrations/CursorHooksInstaller.ts +++ b/src/services/integrations/CursorHooksInstaller.ts @@ -117,7 +117,11 @@ export async function updateCursorContextForProject(projectName: string, _port: logger.debug('CURSOR', 'Updated context file', { projectName, workspacePath: entry.workspacePath }); } catch (error) { // [ANTI-PATTERN IGNORED]: Background context update - failure is non-critical, user workflow continues - logger.error('CURSOR', 'Failed to update context file', { projectName }, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Failed to update context file', { projectName }, error); + } else { + logger.error('WORKER', 'Failed to update context file', { projectName }, new Error(String(error))); + } } } @@ -259,7 +263,11 @@ export function configureCursorMcp(target: CursorInstallTarget): number { } } catch (error) { // [ANTI-PATTERN IGNORED]: Fallback behavior - corrupt config, continue with empty - logger.error('SYSTEM', 'Corrupt mcp.json, creating new config', { path: mcpJsonPath }, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Corrupt mcp.json, creating new config', { path: mcpJsonPath }, error); + } else { + logger.error('WORKER', 'Corrupt mcp.json, creating new config', { path: mcpJsonPath }, new Error(String(error))); + } config = { mcpServers: {} }; } } @@ -308,60 +316,80 @@ export async function installCursorHooks(target: CursorInstallTarget): Promise { - return `"${escapedBunPath}" "${escapedWorkerPath}" hook cursor ${command}`; - }; + // Helper to create hook command using unified CLI with bun runtime + const makeHookCommand = (command: string) => { + return `"${escapedBunPath}" "${escapedWorkerPath}" hook cursor ${command}`; + }; - console.log(` Using Bun runtime: ${bunPath}`); + console.log(` Using Bun runtime: ${bunPath}`); - const hooksJson: CursorHooksJson = { - version: 1, - hooks: { - beforeSubmitPrompt: [ - { command: makeHookCommand('session-init') }, - { command: makeHookCommand('context') } - ], - afterMCPExecution: [ - { command: makeHookCommand('observation') } - ], - afterShellExecution: [ - { command: makeHookCommand('observation') } - ], - afterFileEdit: [ - { command: makeHookCommand('file-edit') } - ], - stop: [ - { command: makeHookCommand('summarize') } - ] - } - }; - - writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2)); - console.log(` Created hooks.json (unified CLI mode)`); - console.log(` Worker service: ${workerServicePath}`); - - // For project-level: create initial context file - if (target === 'project') { - await setupProjectContext(targetDir, workspaceRoot); + const hooksJson: CursorHooksJson = { + version: 1, + hooks: { + beforeSubmitPrompt: [ + { command: makeHookCommand('session-init') }, + { command: makeHookCommand('context') } + ], + afterMCPExecution: [ + { command: makeHookCommand('observation') } + ], + afterShellExecution: [ + { command: makeHookCommand('observation') } + ], + afterFileEdit: [ + { command: makeHookCommand('file-edit') } + ], + stop: [ + { command: makeHookCommand('summarize') } + ] } + }; - console.log(` + try { + await writeHooksJsonAndSetupProject(hooksJsonPath, hooksJson, workerServicePath, target, targetDir, workspaceRoot); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nInstallation failed: ${message}`); + if (target === 'enterprise') { + console.error(' Tip: Enterprise installation may require sudo/admin privileges'); + } + return 1; + } +} + +async function writeHooksJsonAndSetupProject( + hooksJsonPath: string, + hooksJson: CursorHooksJson, + workerServicePath: string, + target: CursorInstallTarget, + targetDir: string, + workspaceRoot: string, +): Promise { + writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2)); + console.log(` Created hooks.json (unified CLI mode)`); + console.log(` Worker service: ${workerServicePath}`); + + // For project-level: create initial context file + if (target === 'project') { + await setupProjectContext(targetDir, workspaceRoot); + } + + console.log(` Installation complete! Hooks installed to: ${targetDir}/hooks.json @@ -376,15 +404,6 @@ Context Injection: Context from past sessions is stored in .cursor/rules/claude-mem-context.mdc and automatically included in every chat. It updates after each session ends. `); - - return 0; - } catch (error) { - console.error(`\nInstallation failed: ${(error as Error).message}`); - if (target === 'enterprise') { - console.error(' Tip: Enterprise installation may require sudo/admin privileges'); - } - return 1; - } } /** @@ -400,25 +419,14 @@ async function setupProjectContext(targetDir: string, workspaceRoot: string): Pr console.log(` Generating initial context...`); try { - // Check if worker is running (uses socket or TCP automatically) - const healthResponse = await workerHttpRequest('/api/readiness'); - if (healthResponse.ok) { - // Fetch context - const contextResponse = await workerHttpRequest( - `/api/context/inject?project=${encodeURIComponent(projectName)}` - ); - if (contextResponse.ok) { - const context = await contextResponse.text(); - if (context && context.trim()) { - writeContextFile(workspaceRoot, context); - contextGenerated = true; - console.log(` Generated initial context from existing memory`); - } - } - } + contextGenerated = await fetchInitialContextFromWorker(projectName, workspaceRoot); } catch (error) { // [ANTI-PATTERN IGNORED]: Fallback behavior - worker not running, use placeholder - logger.debug('CURSOR', 'Worker not running during install', {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', 'Worker not running during install', {}, error); + } else { + logger.debug('WORKER', 'Worker not running during install', {}, new Error(String(error))); + } } if (!contextGenerated) { @@ -444,6 +452,27 @@ Use claude-mem's MCP search tools for manual memory queries. console.log(` Registered for auto-context updates`); } +async function fetchInitialContextFromWorker( + projectName: string, + workspaceRoot: string, +): Promise { + const healthResponse = await workerHttpRequest('/api/readiness'); + if (!healthResponse.ok) return false; + + const contextResponse = await workerHttpRequest( + `/api/context/inject?project=${encodeURIComponent(projectName)}`, + ); + if (!contextResponse.ok) return false; + + const context = await contextResponse.text(); + if (context && context.trim()) { + writeContextFile(workspaceRoot, context); + console.log(` Generated initial context from existing memory`); + return true; + } + return false; +} + /** * Uninstall Cursor hooks */ @@ -456,56 +485,63 @@ export function uninstallCursorHooks(target: CursorInstallTarget): number { return 1; } + const hooksDir = path.join(targetDir, 'hooks'); + const hooksJsonPath = path.join(targetDir, 'hooks.json'); + + // Remove legacy shell scripts if they exist (from old installations) + const bashScripts = ['common.sh', 'session-init.sh', 'context-inject.sh', + 'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh']; + const psScripts = ['common.ps1', 'session-init.ps1', 'context-inject.ps1', + 'save-observation.ps1', 'save-file-edit.ps1', 'session-summary.ps1']; + + const allScripts = [...bashScripts, ...psScripts]; + try { - const hooksDir = path.join(targetDir, 'hooks'); - const hooksJsonPath = path.join(targetDir, 'hooks.json'); - - // Remove legacy shell scripts if they exist (from old installations) - const bashScripts = ['common.sh', 'session-init.sh', 'context-inject.sh', - 'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh']; - const psScripts = ['common.ps1', 'session-init.ps1', 'context-inject.ps1', - 'save-observation.ps1', 'save-file-edit.ps1', 'session-summary.ps1']; - - const allScripts = [...bashScripts, ...psScripts]; - - for (const script of allScripts) { - const scriptPath = path.join(hooksDir, script); - if (existsSync(scriptPath)) { - unlinkSync(scriptPath); - console.log(` Removed legacy script: ${script}`); - } - } - - // Remove hooks.json - if (existsSync(hooksJsonPath)) { - unlinkSync(hooksJsonPath); - console.log(` Removed hooks.json`); - } - - // Remove context file and unregister if project-level - if (target === 'project') { - const contextFile = path.join(targetDir, 'rules', 'claude-mem-context.mdc'); - if (existsSync(contextFile)) { - unlinkSync(contextFile); - console.log(` Removed context file`); - } - - // Unregister from auto-context updates - const projectName = path.basename(process.cwd()); - unregisterCursorProject(projectName); - console.log(` Unregistered from auto-context updates`); - } - - console.log(`\nUninstallation complete!\n`); - console.log('Restart Cursor to apply changes.'); - + removeCursorHooksFiles(hooksDir, allScripts, hooksJsonPath, target, targetDir); return 0; } catch (error) { - console.error(`\nUninstallation failed: ${(error as Error).message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(`\nUninstallation failed: ${message}`); return 1; } } +function removeCursorHooksFiles( + hooksDir: string, + allScripts: string[], + hooksJsonPath: string, + target: CursorInstallTarget, + targetDir: string, +): void { + for (const script of allScripts) { + const scriptPath = path.join(hooksDir, script); + if (existsSync(scriptPath)) { + unlinkSync(scriptPath); + console.log(` Removed legacy script: ${script}`); + } + } + + if (existsSync(hooksJsonPath)) { + unlinkSync(hooksJsonPath); + console.log(` Removed hooks.json`); + } + + if (target === 'project') { + const contextFile = path.join(targetDir, 'rules', 'claude-mem-context.mdc'); + if (existsSync(contextFile)) { + unlinkSync(contextFile); + console.log(` Removed context file`); + } + + const projectName = path.basename(process.cwd()); + unregisterCursorProject(projectName); + console.log(` Unregistered from auto-context updates`); + } + + console.log(`\nUninstallation complete!\n`); + console.log('Restart Cursor to apply changes.'); +} + /** * Check Cursor hooks installation status */ @@ -535,8 +571,19 @@ export function checkCursorHooksStatus(): number { console.log(` Config: ${hooksJson}`); // Check if using unified CLI mode or legacy shell scripts + let hooksContent: any = null; try { - const hooksContent = JSON.parse(readFileSync(hooksJson, 'utf-8')); + hooksContent = JSON.parse(readFileSync(hooksJson, 'utf-8')); + } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Unable to parse hooks.json', { path: hooksJson }, error); + } else { + logger.error('WORKER', 'Unable to parse hooks.json', { path: hooksJson }, new Error(String(error))); + } + console.log(` Mode: Unable to parse hooks.json`); + } + + if (hooksContent) { const firstCommand = hooksContent?.hooks?.beforeSubmitPrompt?.[0]?.command || ''; if (firstCommand.includes('worker-service.cjs') && firstCommand.includes('hook cursor')) { @@ -562,8 +609,6 @@ export function checkCursorHooksStatus(): number { console.log(` Mode: Unknown configuration`); } } - } catch { - console.log(` Mode: Unable to parse hooks.json`); } // Check for context file (project only) @@ -601,7 +646,11 @@ export async function detectClaudeCode(): Promise { } } catch (error) { // [ANTI-PATTERN IGNORED]: Fallback behavior - CLI not found, continue to directory check - logger.debug('SYSTEM', 'Claude CLI not in PATH', {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', 'Claude CLI not in PATH', {}, error); + } else { + logger.debug('WORKER', 'Claude CLI not in PATH', {}, new Error(String(error))); + } } // Check for Claude Code plugin directory (respects CLAUDE_CONFIG_DIR) diff --git a/src/services/integrations/GeminiCliHooksInstaller.ts b/src/services/integrations/GeminiCliHooksInstaller.ts index e9aab464..08363abd 100644 --- a/src/services/integrations/GeminiCliHooksInstaller.ts +++ b/src/services/integrations/GeminiCliHooksInstaller.ts @@ -162,6 +162,11 @@ function readGeminiSettings(): GeminiSettingsJson { try { return JSON.parse(content) as GeminiSettingsJson; } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Corrupt JSON in Gemini settings', { path: GEMINI_SETTINGS_PATH }, error); + } else { + logger.error('WORKER', 'Corrupt JSON in Gemini settings', { path: GEMINI_SETTINGS_PATH }, new Error(String(error))); + } throw new Error(`Corrupt JSON in ${GEMINI_SETTINGS_PATH}, refusing to overwrite user settings`); } } @@ -286,35 +291,42 @@ export async function installGeminiCliHooks(): Promise { console.log(` Using Bun runtime: ${bunPath}`); console.log(` Worker service: ${workerServicePath}`); + // Build hook commands for all mapped events + const hooksConfig: GeminiHooksConfig = {}; + for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) { + const command = buildHookCommand(bunPath, workerServicePath, geminiEvent); + hooksConfig[geminiEvent] = [createHookGroup(command)]; + } + + // Read existing settings and merge + const existingSettings = readGeminiSettings(); + const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig); + try { - // Build hook commands for all mapped events - const hooksConfig: GeminiHooksConfig = {}; - for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) { - const command = buildHookCommand(bunPath, workerServicePath, geminiEvent); - hooksConfig[geminiEvent] = [createHookGroup(command)]; - } + writeGeminiHooksAndSetupContext(mergedSettings); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nInstallation failed: ${message}`); + return 1; + } +} - // Read existing settings and merge - const existingSettings = readGeminiSettings(); - const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig); +function writeGeminiHooksAndSetupContext(mergedSettings: GeminiSettingsJson): void { + writeGeminiSettings(mergedSettings); + console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`); - // Write back - writeGeminiSettings(mergedSettings); - console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`); + setupGeminiMdContextSection(); + console.log(` Setup context injection in ${GEMINI_MD_PATH}`); - // Setup GEMINI.md context injection - setupGeminiMdContextSection(); - console.log(` Setup context injection in ${GEMINI_MD_PATH}`); + const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT); + console.log(` Registered ${eventNames.length} hook events:`); + for (const event of eventNames) { + const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event]; + console.log(` ${event} → ${internalEvent}`); + } - // List installed events - const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT); - console.log(` Registered ${eventNames.length} hook events:`); - for (const event of eventNames) { - const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event]; - console.log(` ${event} → ${internalEvent}`); - } - - console.log(` + console.log(` Installation complete! Hooks installed to: ${GEMINI_SETTINGS_PATH} @@ -329,12 +341,6 @@ Context Injection: Context from past sessions is injected via ~/.gemini/GEMINI.md and automatically included in Gemini CLI conversations. `); - - return 0; - } catch (error) { - console.error(`\nInstallation failed: ${(error as Error).message}`); - return 1; - } } /** @@ -347,65 +353,72 @@ Context Injection: export function uninstallGeminiCliHooks(): number { console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n'); + if (!existsSync(GEMINI_SETTINGS_PATH)) { + console.log(' No Gemini CLI settings found — nothing to uninstall.'); + return 0; + } + + const settings = readGeminiSettings(); + if (!settings.hooks) { + console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.'); + return 0; + } + + let removedCount = 0; + + // Remove claude-mem hooks from within each group, preserving other hooks + for (const [eventName, groups] of Object.entries(settings.hooks)) { + const filteredGroups = groups + .map(group => { + const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME); + removedCount += group.hooks.length - remainingHooks.length; + return { ...group, hooks: remainingHooks }; + }) + .filter(group => group.hooks.length > 0); + + if (filteredGroups.length > 0) { + settings.hooks[eventName] = filteredGroups; + } else { + delete settings.hooks[eventName]; + } + } + + // Clean up empty hooks object + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + try { - if (!existsSync(GEMINI_SETTINGS_PATH)) { - console.log(' No Gemini CLI settings found — nothing to uninstall.'); - return 0; - } - - const settings = readGeminiSettings(); - if (!settings.hooks) { - console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.'); - return 0; - } - - let removedCount = 0; - - // Remove claude-mem hooks from within each group, preserving other hooks - for (const [eventName, groups] of Object.entries(settings.hooks)) { - const filteredGroups = groups - .map(group => { - const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME); - removedCount += group.hooks.length - remainingHooks.length; - return { ...group, hooks: remainingHooks }; - }) - .filter(group => group.hooks.length > 0); - - if (filteredGroups.length > 0) { - settings.hooks[eventName] = filteredGroups; - } else { - delete settings.hooks[eventName]; - } - } - - // Clean up empty hooks object - if (Object.keys(settings.hooks).length === 0) { - delete settings.hooks; - } - - writeGeminiSettings(settings); - console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`); - - // Remove claude-mem context section from GEMINI.md - if (existsSync(GEMINI_MD_PATH)) { - let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8'); - const contextRegex = /\n?[\s\S]*?<\/claude-mem-context>\n?/; - if (contextRegex.test(mdContent)) { - mdContent = mdContent.replace(contextRegex, ''); - writeFileSync(GEMINI_MD_PATH, mdContent); - console.log(` Removed context section from ${GEMINI_MD_PATH}`); - } - } - - console.log('\nUninstallation complete!\n'); - console.log('Restart Gemini CLI to apply changes.'); + writeSettingsAndCleanupGeminiContext(settings, removedCount); return 0; } catch (error) { - console.error(`\nUninstallation failed: ${(error as Error).message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(`\nUninstallation failed: ${message}`); return 1; } } +function writeSettingsAndCleanupGeminiContext( + settings: GeminiSettingsJson, + removedCount: number, +): void { + writeGeminiSettings(settings); + console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`); + + if (existsSync(GEMINI_MD_PATH)) { + let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8'); + const contextRegex = /\n?[\s\S]*?<\/claude-mem-context>\n?/; + if (contextRegex.test(mdContent)) { + mdContent = mdContent.replace(contextRegex, ''); + writeFileSync(GEMINI_MD_PATH, mdContent); + console.log(` Removed context section from ${GEMINI_MD_PATH}`); + } + } + + console.log('\nUninstallation complete!\n'); + console.log('Restart Gemini CLI to apply changes.'); +} + /** * Check Gemini CLI hooks installation status. * @@ -425,7 +438,13 @@ export function checkGeminiCliHooksStatus(): number { try { settings = readGeminiSettings(); } catch (error) { - console.log(`Gemini CLI settings: ${(error as Error).message}\n`); + const message = error instanceof Error ? error.message : String(error); + if (error instanceof Error) { + logger.error('WORKER', 'Failed to read Gemini CLI settings', { path: GEMINI_SETTINGS_PATH }, error); + } else { + logger.error('WORKER', 'Failed to read Gemini CLI settings', { path: GEMINI_SETTINGS_PATH }, new Error(String(error))); + } + console.log(`Gemini CLI settings: ${message}\n`); return 0; } diff --git a/src/services/integrations/McpIntegrations.ts b/src/services/integrations/McpIntegrations.ts index 88aa2c27..4dba7a57 100644 --- a/src/services/integrations/McpIntegrations.ts +++ b/src/services/integrations/McpIntegrations.ts @@ -105,53 +105,65 @@ function installMcpIntegration(config: McpInstallerConfig): () => Promise { return 1; } + const configPath = getGooseConfigPath(); + const configDirectory = path.dirname(configPath); + mkdirSync(configDirectory, { recursive: true }); + try { - const configPath = getGooseConfigPath(); - const configDirectory = path.dirname(configPath); - mkdirSync(configDirectory, { recursive: true }); + mergeGooseYamlConfig(configPath, mcpServerPath); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nInstallation failed: ${message}`); + return 1; + } +} - if (existsSync(configPath)) { - let yamlContent = readFileSync(configPath, 'utf-8'); +function mergeGooseYamlConfig(configPath: string, mcpServerPath: string): void { + if (existsSync(configPath)) { + let yamlContent = readFileSync(configPath, 'utf-8'); - if (gooseConfigHasClaudeMemEntry(yamlContent)) { - // Already configured — replace the claude-mem block - // Find the claude-mem entry and replace it - const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|^\S|$))/m; - const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n'; + if (gooseConfigHasClaudeMemEntry(yamlContent)) { + const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|^\S|$))/m; + const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n'; - if (claudeMemPattern.test(yamlContent)) { - yamlContent = yamlContent.replace(claudeMemPattern, newEntry); - } - writeFileSync(configPath, yamlContent); - console.log(` Updated existing claude-mem entry in: ${configPath}`); - } else if (yamlContent.includes('mcpServers:')) { - // mcpServers section exists but no claude-mem entry — append under it - const mcpServersIndex = yamlContent.indexOf('mcpServers:'); - const insertionPoint = mcpServersIndex + 'mcpServers:'.length; - const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath); - - yamlContent = - yamlContent.slice(0, insertionPoint) + - newEntry + - yamlContent.slice(insertionPoint); - - writeFileSync(configPath, yamlContent); - console.log(` Added claude-mem to existing mcpServers in: ${configPath}`); - } else { - // No mcpServers section — append the entire block - const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n'; - yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock; - writeFileSync(configPath, yamlContent); - console.log(` Appended mcpServers section to: ${configPath}`); + if (claudeMemPattern.test(yamlContent)) { + yamlContent = yamlContent.replace(claudeMemPattern, newEntry); } - } else { - // File doesn't exist — create from template - const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n'; - writeFileSync(configPath, templateContent); - console.log(` Created config with MCP server: ${configPath}`); - } + writeFileSync(configPath, yamlContent); + console.log(` Updated existing claude-mem entry in: ${configPath}`); + } else if (yamlContent.includes('mcpServers:')) { + const mcpServersIndex = yamlContent.indexOf('mcpServers:'); + const insertionPoint = mcpServersIndex + 'mcpServers:'.length; + const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath); - console.log(` + yamlContent = + yamlContent.slice(0, insertionPoint) + + newEntry + + yamlContent.slice(insertionPoint); + + writeFileSync(configPath, yamlContent); + console.log(` Added claude-mem to existing mcpServers in: ${configPath}`); + } else { + const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n'; + yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock; + writeFileSync(configPath, yamlContent); + console.log(` Appended mcpServers section to: ${configPath}`); + } + } else { + const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n'; + writeFileSync(configPath, templateContent); + console.log(` Created config with MCP server: ${configPath}`); + } + + console.log(` Installation complete! MCP config: ${configPath} @@ -332,12 +349,6 @@ Next steps: 1. Start claude-mem worker: npx claude-mem start 2. Restart Goose to pick up the MCP server `); - - return 0; - } catch (error) { - console.error(`\nInstallation failed: ${(error as Error).message}`); - return 1; - } } // ============================================================================ diff --git a/src/services/integrations/OpenClawInstaller.ts b/src/services/integrations/OpenClawInstaller.ts index 0867f794..7dca3f32 100644 --- a/src/services/integrations/OpenClawInstaller.ts +++ b/src/services/integrations/OpenClawInstaller.ts @@ -146,7 +146,12 @@ function readOpenClawConfig(): Record { if (!existsSync(configFilePath)) return {}; try { return JSON.parse(readFileSync(configFilePath, 'utf-8')); - } catch { + } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Failed to parse openclaw.json, using empty config', { path: configFilePath }, error); + } else { + logger.error('WORKER', 'Failed to parse openclaw.json, using empty config', { path: configFilePath }, new Error(String(error))); + } return {}; } } @@ -250,49 +255,23 @@ export function installOpenClawPlugin(): number { const extensionDirectory = getOpenClawClaudeMemExtensionDirectory(); const destinationDistDirectory = path.join(extensionDirectory, 'dist'); + // Create the extension directory structure + mkdirSync(destinationDistDirectory, { recursive: true }); + + // Locate optional assets before entering the try block + const manifestPath = findPluginManifestPath(); + const skillsDirectory = findPluginSkillsDirectory(); + + const extensionPackageJson = { + name: 'claude-mem', + version: '1.0.0', + type: 'module', + main: 'dist/index.js', + openclaw: { extensions: ['./dist/index.js'] }, + }; + try { - // Create the extension directory structure - mkdirSync(destinationDistDirectory, { recursive: true }); - - // Copy pre-built dist files - cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true }); - console.log(` Plugin dist copied to: ${destinationDistDirectory}`); - - // Copy openclaw.plugin.json if available - const manifestPath = findPluginManifestPath(); - if (manifestPath) { - const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json'); - cpSync(manifestPath, destinationManifest, { force: true }); - console.log(` Plugin manifest copied to: ${destinationManifest}`); - } - - // Copy skills directory if available - const skillsDirectory = findPluginSkillsDirectory(); - if (skillsDirectory) { - const destinationSkills = path.join(extensionDirectory, 'skills'); - cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true }); - console.log(` Skills copied to: ${destinationSkills}`); - } - - // Create a minimal package.json for the extension (OpenClaw expects this) - const extensionPackageJson = { - name: 'claude-mem', - version: '1.0.0', - type: 'module', - main: 'dist/index.js', - openclaw: { extensions: ['./dist/index.js'] }, - }; - writeFileSync( - path.join(extensionDirectory, 'package.json'), - JSON.stringify(extensionPackageJson, null, 2) + '\n', - 'utf-8', - ); - - // Register in openclaw.json (merge, not overwrite) - registerPluginInOpenClawConfig(); - console.log(` Registered in openclaw.json`); - - logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory }); + copyPluginFilesAndRegister(preBuiltDistDirectory, destinationDistDirectory, extensionDirectory, manifestPath, skillsDirectory, extensionPackageJson); return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -301,6 +280,41 @@ export function installOpenClawPlugin(): number { } } +function copyPluginFilesAndRegister( + preBuiltDistDirectory: string, + destinationDistDirectory: string, + extensionDirectory: string, + manifestPath: string | null, + skillsDirectory: string | null, + extensionPackageJson: Record, +): void { + cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true }); + console.log(` Plugin dist copied to: ${destinationDistDirectory}`); + + if (manifestPath) { + const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json'); + cpSync(manifestPath, destinationManifest, { force: true }); + console.log(` Plugin manifest copied to: ${destinationManifest}`); + } + + if (skillsDirectory) { + const destinationSkills = path.join(extensionDirectory, 'skills'); + cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true }); + console.log(` Skills copied to: ${destinationSkills}`); + } + + writeFileSync( + path.join(extensionDirectory, 'package.json'), + JSON.stringify(extensionPackageJson, null, 2) + '\n', + 'utf-8', + ); + + registerPluginInOpenClawConfig(); + console.log(` Registered in openclaw.json`); + + logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory }); +} + // ============================================================================ // Uninstallation // ============================================================================ diff --git a/src/services/integrations/OpenCodeInstaller.ts b/src/services/integrations/OpenCodeInstaller.ts index e16c29a8..4ea53eba 100644 --- a/src/services/integrations/OpenCodeInstaller.ts +++ b/src/services/integrations/OpenCodeInstaller.ts @@ -164,21 +164,43 @@ export async function syncContextToAgentsMd( project: string, ): Promise { try { - const response = await fetch( - `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`, - ); - - if (!response.ok) return; - - const contextText = await response.text(); - if (contextText && contextText.trim()) { - const injectResult = injectContextIntoAgentsMd(contextText); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject context into AGENTS.md during sync'); - } - } - } catch { + await fetchAndInjectOpenCodeContext(port, project); + } catch (error) { // Worker not available — non-critical + if (error instanceof Error) { + logger.debug('WORKER', 'Worker not available during context sync', {}, error); + } else { + logger.debug('WORKER', 'Worker not available during context sync', {}, new Error(String(error))); + } + } +} + +async function fetchRealContextFromWorker(): Promise { + const workerPort = getWorkerPort(); + const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/readiness`); + if (!healthResponse.ok) return null; + + const contextResponse = await fetch( + `http://127.0.0.1:${workerPort}/api/context/inject?project=opencode`, + ); + if (!contextResponse.ok) return null; + + const realContext = await contextResponse.text(); + return realContext && realContext.trim() ? realContext : null; +} + +async function fetchAndInjectOpenCodeContext(port: number, project: string): Promise { + const response = await fetch( + `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`, + ); + if (!response.ok) return; + + const contextText = await response.text(); + if (contextText && contextText.trim()) { + const injectResult = injectContextIntoAgentsMd(contextText); + if (injectResult !== 0) { + logger.warn('OPENCODE', 'Failed to inject context into AGENTS.md during sync'); + } } } @@ -186,6 +208,19 @@ export async function syncContextToAgentsMd( // Uninstallation // ============================================================================ +function writeOrRemoveCleanedAgentsMd(agentsMdPath: string, trimmedContent: string): void { + if ( + trimmedContent.length === 0 || + trimmedContent === '# Claude-Mem Memory Context' + ) { + unlinkSync(agentsMdPath); + console.log(` Removed empty AGENTS.md`); + } else { + writeFileSync(agentsMdPath, trimmedContent + '\n', 'utf-8'); + console.log(` Cleaned context from AGENTS.md`); + } +} + /** * Remove the claude-mem plugin from OpenCode. * Removes the plugin file and cleans up the AGENTS.md context section. @@ -211,34 +246,33 @@ export function uninstallOpenCodePlugin(): number { // Remove context section from AGENTS.md const agentsMdPath = getOpenCodeAgentsMdPath(); if (existsSync(agentsMdPath)) { + let content: string; try { - let content = readFileSync(agentsMdPath, 'utf-8'); - const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN); - const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE); - - if (tagStartIndex !== -1 && tagEndIndex !== -1) { - content = - content.slice(0, tagStartIndex).trimEnd() + - '\n' + - content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart(); - - // If the file is now essentially empty or only has our header, remove it - const trimmedContent = content.trim(); - if ( - trimmedContent.length === 0 || - trimmedContent === '# Claude-Mem Memory Context' - ) { - unlinkSync(agentsMdPath); - console.log(` Removed empty AGENTS.md`); - } else { - writeFileSync(agentsMdPath, trimmedContent + '\n', 'utf-8'); - console.log(` Cleaned context from AGENTS.md`); - } - } + content = readFileSync(agentsMdPath, 'utf-8'); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(` Failed to clean AGENTS.md: ${message}`); + console.error(` Failed to read AGENTS.md: ${message}`); hasErrors = true; + content = ''; + } + + const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN); + const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE); + + if (tagStartIndex !== -1 && tagEndIndex !== -1) { + content = + content.slice(0, tagStartIndex).trimEnd() + + '\n' + + content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart(); + + const trimmedContent = content.trim(); + try { + writeOrRemoveCleanedAgentsMd(agentsMdPath, trimmedContent); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` Failed to clean AGENTS.md: ${message}`); + hasErrors = true; + } } } @@ -309,48 +343,29 @@ export async function installOpenCodeIntegration(): Promise { Use claude-mem search tools for manual memory queries.`; // Try to fetch real context from worker first + let contextToInject = placeholderContext; + let contextSource = 'placeholder'; try { - const workerPort = getWorkerPort(); - const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/readiness`); - if (healthResponse.ok) { - const contextResponse = await fetch( - `http://127.0.0.1:${workerPort}/api/context/inject?project=opencode`, - ); - if (contextResponse.ok) { - const realContext = await contextResponse.text(); - if (realContext && realContext.trim()) { - const injectResult = injectContextIntoAgentsMd(realContext); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject real context into AGENTS.md during install'); - } else { - console.log(' Context injected from existing memory'); - } - } else { - const injectResult = injectContextIntoAgentsMd(placeholderContext); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install'); - } else { - console.log(' Placeholder context created (will populate after first session)'); - } - } - } else { - const injectResult = injectContextIntoAgentsMd(placeholderContext); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install'); - } - } - } else { - const injectResult = injectContextIntoAgentsMd(placeholderContext); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install'); - } else { - console.log(' Placeholder context created (worker not running)'); - } + const realContext = await fetchRealContextFromWorker(); + if (realContext) { + contextToInject = realContext; + contextSource = 'existing memory'; } - } catch { - const injectResult = injectContextIntoAgentsMd(placeholderContext); - if (injectResult !== 0) { - logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install'); + } catch (error) { + // Worker not available — use placeholder + if (error instanceof Error) { + logger.debug('WORKER', 'Worker not available during OpenCode install', {}, error); + } else { + logger.debug('WORKER', 'Worker not available during OpenCode install', {}, new Error(String(error))); + } + } + + const injectResult = injectContextIntoAgentsMd(contextToInject); + if (injectResult !== 0) { + logger.warn('OPENCODE', `Failed to inject ${contextSource} context into AGENTS.md during install`); + } else { + if (contextSource === 'existing memory') { + console.log(' Context injected from existing memory'); } else { console.log(' Placeholder context created (worker not running)'); } diff --git a/src/services/integrations/WindsurfHooksInstaller.ts b/src/services/integrations/WindsurfHooksInstaller.ts index 6c6b213b..3251012d 100644 --- a/src/services/integrations/WindsurfHooksInstaller.ts +++ b/src/services/integrations/WindsurfHooksInstaller.ts @@ -86,9 +86,11 @@ export function readWindsurfRegistry(): WindsurfProjectRegistry { if (!existsSync(WINDSURF_REGISTRY_FILE)) return {}; return JSON.parse(readFileSync(WINDSURF_REGISTRY_FILE, 'utf-8')); } catch (error) { - logger.error('WINDSURF', 'Failed to read registry, using empty', { - file: WINDSURF_REGISTRY_FILE, - }, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Failed to read registry, using empty', { file: WINDSURF_REGISTRY_FILE }, error); + } else { + logger.error('WORKER', 'Failed to read registry, using empty', { file: WINDSURF_REGISTRY_FILE }, new Error(String(error))); + } return {}; } } @@ -151,7 +153,11 @@ export async function updateWindsurfContextForProject(projectName: string, works logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath }); } catch (error) { // Background context update — failure is non-critical - logger.error('WINDSURF', 'Failed to update context file', { projectName, workspacePath }, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Failed to update context file', { projectName, workspacePath }, error); + } else { + logger.error('WORKER', 'Failed to update context file', { projectName, workspacePath }, new Error(String(error))); + } } } @@ -235,6 +241,11 @@ function mergeAndWriteHooksJson( existingConfig.hooks = {}; } } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Corrupt hooks.json, refusing to overwrite', { path: WINDSURF_HOOKS_JSON_PATH }, error); + } else { + logger.error('WORKER', 'Corrupt hooks.json, refusing to overwrite', { path: WINDSURF_HOOKS_JSON_PATH }, new Error(String(error))); + } throw new Error(`Corrupt hooks.json at ${WINDSURF_HOOKS_JSON_PATH}, refusing to overwrite`); } } @@ -286,19 +297,33 @@ export async function installWindsurfHooks(): Promise { // IMPORTANT: Tilde expansion is NOT supported in working_directory — use absolute paths const workingDirectory = path.dirname(workerServicePath); + console.log(` Using Bun runtime: ${bunPath}`); + console.log(` Worker service: ${workerServicePath}`); + + const workspaceRoot = process.cwd(); + try { - console.log(` Using Bun runtime: ${bunPath}`); - console.log(` Worker service: ${workerServicePath}`); + await writeWindsurfHooksAndSetupContext(bunPath, workerServicePath, workingDirectory, workspaceRoot); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nInstallation failed: ${message}`); + return 1; + } +} - // Merge our hooks into the existing hooks.json - mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory); - console.log(` Created/merged hooks.json`); +async function writeWindsurfHooksAndSetupContext( + bunPath: string, + workerServicePath: string, + workingDirectory: string, + workspaceRoot: string, +): Promise { + mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory); + console.log(` Created/merged hooks.json`); - // Set up initial context for the current workspace - const workspaceRoot = process.cwd(); - await setupWindsurfProjectContext(workspaceRoot); + await setupWindsurfProjectContext(workspaceRoot); - console.log(` + console.log(` Installation complete! Hooks installed to: ${WINDSURF_HOOKS_JSON_PATH} @@ -316,12 +341,6 @@ Next steps: 2. Restart Windsurf to load the hooks 3. Context is injected via .windsurf/rules/claude-mem-context.md (workspace-level) `); - - return 0; - } catch (error) { - console.error(`\nInstallation failed: ${(error as Error).message}`); - return 1; - } } /** @@ -335,23 +354,14 @@ async function setupWindsurfProjectContext(workspaceRoot: string): Promise console.log(` Generating initial context...`); try { - const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`); - if (healthResponse.ok) { - const contextResponse = await fetch( - `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}` - ); - if (contextResponse.ok) { - const context = await contextResponse.text(); - if (context && context.trim()) { - writeWindsurfContextFile(workspaceRoot, context); - contextGenerated = true; - console.log(` Generated initial context from existing memory`); - } - } - } + contextGenerated = await fetchWindsurfContextFromWorker(port, projectName, workspaceRoot); } catch (error) { // Worker not running during install — non-critical - logger.debug('WINDSURF', 'Worker not running during install', {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', 'Worker not running during install', {}, error); + } else { + logger.debug('WORKER', 'Worker not running during install', {}, new Error(String(error))); + } } if (!contextGenerated) { @@ -374,67 +384,99 @@ Use claude-mem's MCP search tools for manual memory queries. console.log(` Registered for auto-context updates`); } +async function fetchWindsurfContextFromWorker( + port: number, + projectName: string, + workspaceRoot: string, +): Promise { + const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`); + if (!healthResponse.ok) return false; + + const contextResponse = await fetch( + `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`, + ); + if (!contextResponse.ok) return false; + + const context = await contextResponse.text(); + if (context && context.trim()) { + writeWindsurfContextFile(workspaceRoot, context); + console.log(` Generated initial context from existing memory`); + return true; + } + return false; +} + /** * Uninstall Windsurf hooks — removes claude-mem entries from hooks.json */ export function uninstallWindsurfHooks(): number { console.log('\nUninstalling Claude-Mem Windsurf hooks...\n'); - try { - // Remove our entries from hooks.json (preserve other integrations) - if (existsSync(WINDSURF_HOOKS_JSON_PATH)) { - try { - const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8')); - - for (const eventName of WINDSURF_HOOK_EVENTS) { - if (config.hooks[eventName]) { - config.hooks[eventName] = config.hooks[eventName].filter( - (hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf') - ); - // Remove empty arrays - if (config.hooks[eventName].length === 0) { - delete config.hooks[eventName]; - } - } - } - - // If no hooks remain, remove the file entirely - if (Object.keys(config.hooks).length === 0) { - unlinkSync(WINDSURF_HOOKS_JSON_PATH); - console.log(` Removed hooks.json (no hooks remaining)`); - } else { - writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2)); - console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`); - } - } catch (error) { - console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`); + // Remove our entries from hooks.json (preserve other integrations) + if (existsSync(WINDSURF_HOOKS_JSON_PATH)) { + try { + removeClaudeMemHookEntries(); + } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Could not parse hooks.json during uninstall', { path: WINDSURF_HOOKS_JSON_PATH }, error); + } else { + logger.error('WORKER', 'Could not parse hooks.json during uninstall', { path: WINDSURF_HOOKS_JSON_PATH }, new Error(String(error))); } - } else { - console.log(` No hooks.json found`); + console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`); } + } else { + console.log(` No hooks.json found`); + } - // Remove context file from the current workspace - const workspaceRoot = process.cwd(); - const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md'); - if (existsSync(contextFile)) { - unlinkSync(contextFile); - console.log(` Removed context file`); - } - - // Unregister project - unregisterWindsurfProject(workspaceRoot); - console.log(` Unregistered from auto-context updates`); - - console.log(`\nUninstallation complete!\n`); - console.log('Restart Windsurf to apply changes.'); + const workspaceRoot = process.cwd(); + try { + removeWindsurfContextAndUnregister(workspaceRoot); return 0; } catch (error) { - console.error(`\nUninstallation failed: ${(error as Error).message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(`\nUninstallation failed: ${message}`); return 1; } } +function removeClaudeMemHookEntries(): void { + const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8')); + + for (const eventName of WINDSURF_HOOK_EVENTS) { + if (config.hooks[eventName]) { + config.hooks[eventName] = config.hooks[eventName].filter( + (hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf'), + ); + if (config.hooks[eventName].length === 0) { + delete config.hooks[eventName]; + } + } + } + + if (Object.keys(config.hooks).length === 0) { + unlinkSync(WINDSURF_HOOKS_JSON_PATH); + console.log(` Removed hooks.json (no hooks remaining)`); + } else { + writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2)); + console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`); + } +} + +function removeWindsurfContextAndUnregister(workspaceRoot: string): void { + const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md'); + if (existsSync(contextFile)) { + unlinkSync(contextFile); + console.log(` Removed context file`); + } + + unregisterWindsurfProject(workspaceRoot); + console.log(` Unregistered from auto-context updates`); + + console.log(`\nUninstallation complete!\n`); + console.log('Restart Windsurf to apply changes.'); +} + /** * Check Windsurf hooks installation status */ @@ -445,10 +487,21 @@ export function checkWindsurfHooksStatus(): number { console.log(`User-level: Installed`); console.log(` Config: ${WINDSURF_HOOKS_JSON_PATH}`); + let parsedConfig: WindsurfHooksJson | null = null; try { - const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8')); + parsedConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8')); + } catch (error) { + if (error instanceof Error) { + logger.error('WORKER', 'Unable to parse hooks.json', { path: WINDSURF_HOOKS_JSON_PATH }, error); + } else { + logger.error('WORKER', 'Unable to parse hooks.json', { path: WINDSURF_HOOKS_JSON_PATH }, new Error(String(error))); + } + console.log(` Mode: Unable to parse hooks.json`); + } + + if (parsedConfig) { const registeredEvents = WINDSURF_HOOK_EVENTS.filter( - (event) => config.hooks[event]?.some( + (event) => parsedConfig!.hooks[event]?.some( (hook) => hook.command.includes('worker-service') && hook.command.includes('windsurf') ) ); @@ -456,8 +509,6 @@ export function checkWindsurfHooksStatus(): number { for (const event of registeredEvents) { console.log(` - ${event}`); } - } catch { - console.log(` Mode: Unable to parse hooks.json`); } // Check for context file in current workspace diff --git a/src/services/queue/SessionQueueProcessor.ts b/src/services/queue/SessionQueueProcessor.ts index 9b29d968..33a91da0 100644 --- a/src/services/queue/SessionQueueProcessor.ts +++ b/src/services/queue/SessionQueueProcessor.ts @@ -34,40 +34,38 @@ export class SessionQueueProcessor { let lastActivityTime = Date.now(); while (!signal.aborted) { + // Claim phase: atomically claim next pending message (marks as 'processing') + // Self-heals any stale processing messages before claiming + let persistentMessage: PersistentPendingMessage | null = null; try { - // Atomically claim next pending message (marks as 'processing') - // Self-heals any stale processing messages before claiming - const persistentMessage = this.store.claimNextMessage(sessionDbId); - - if (persistentMessage) { - // Reset activity time when we successfully yield a message - lastActivityTime = Date.now(); - // Yield the message for processing (it's marked as 'processing' in DB) - yield this.toPendingMessageWithId(persistentMessage); - } else { - // Queue empty - wait for wake-up event or timeout - const receivedMessage = await this.waitForMessage(signal, IDLE_TIMEOUT_MS); - - if (!receivedMessage && !signal.aborted) { - // Timeout occurred - check if we've been idle too long - const idleDuration = Date.now() - lastActivityTime; - if (idleDuration >= IDLE_TIMEOUT_MS) { - logger.info('SESSION', 'Idle timeout reached, triggering abort to kill subprocess', { - sessionDbId, - idleDurationMs: idleDuration, - thresholdMs: IDLE_TIMEOUT_MS - }); - onIdleTimeout?.(); - return; - } - // Reset timer on spurious wakeup - queue is empty but duration check failed - lastActivityTime = Date.now(); - } - } + persistentMessage = this.store.claimNextMessage(sessionDbId); } catch (error) { if (signal.aborted) return; - logger.error('SESSION', 'Error in queue processor loop', { sessionDbId }, error as Error); - // Small backoff to prevent tight loop on DB error + const normalizedError = error instanceof Error ? error : new Error(String(error)); + logger.error('QUEUE', 'Failed to claim next message', { sessionDbId }, normalizedError); + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + + if (persistentMessage) { + // Reset activity time when we successfully yield a message + lastActivityTime = Date.now(); + // Yield the message for processing (it's marked as 'processing' in DB) + yield this.toPendingMessageWithId(persistentMessage); + continue; + } + + // Wait phase: queue empty - wait for wake-up event or timeout + try { + const idleTimedOut = await this.handleWaitPhase(signal, lastActivityTime, sessionDbId, onIdleTimeout); + if (idleTimedOut) return; + // Reset timer on spurious wakeup if not timed out + lastActivityTime = Date.now(); + } catch (error) { + if (signal.aborted) return; + const normalizedError = error instanceof Error ? error : new Error(String(error)); + logger.error('QUEUE', 'Error waiting for message', { sessionDbId }, normalizedError); + // Small backoff to prevent tight loop on error await new Promise(resolve => setTimeout(resolve, 1000)); } } @@ -82,6 +80,33 @@ export class SessionQueueProcessor { }; } + /** + * Handle the wait phase: wait for a message or check idle timeout. + * @returns true if idle timeout was reached (caller should return/exit iterator) + */ + private async handleWaitPhase( + signal: AbortSignal, + lastActivityTime: number, + sessionDbId: number, + onIdleTimeout?: () => void + ): Promise { + const receivedMessage = await this.waitForMessage(signal, IDLE_TIMEOUT_MS); + + if (!receivedMessage && !signal.aborted) { + const idleDuration = Date.now() - lastActivityTime; + if (idleDuration >= IDLE_TIMEOUT_MS) { + logger.info('SESSION', 'Idle timeout reached, triggering abort to kill subprocess', { + sessionDbId, + idleDurationMs: idleDuration, + thresholdMs: IDLE_TIMEOUT_MS + }); + onIdleTimeout?.(); + return true; + } + } + return false; + } + /** * Wait for a message event or timeout. * @param signal - AbortSignal to cancel waiting diff --git a/src/services/server/Server.ts b/src/services/server/Server.ts index 528e19d7..3759cedc 100644 --- a/src/services/server/Server.ts +++ b/src/services/server/Server.ts @@ -208,31 +208,27 @@ export class Server { return res.status(400).json({ error: 'Invalid topic' }); } - try { - let content: string; + if (operation && !ALLOWED_OPERATIONS.includes(operation)) { + return res.status(400).json({ error: 'Invalid operation' }); + } - if (operation) { - // Validate operation - if (!ALLOWED_OPERATIONS.includes(operation)) { - return res.status(400).json({ error: 'Invalid operation' }); - } - // Path boundary check - const OPERATIONS_BASE_DIR = path.resolve(__dirname, '../skills/mem-search/operations'); - const operationPath = path.resolve(OPERATIONS_BASE_DIR, `${operation}.md`); - if (!operationPath.startsWith(OPERATIONS_BASE_DIR + path.sep)) { - return res.status(400).json({ error: 'Invalid request' }); - } - content = await fs.promises.readFile(operationPath, 'utf-8'); - } else { - const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md'); - const fullContent = await fs.promises.readFile(skillPath, 'utf-8'); - content = this.extractInstructionSection(fullContent, topic); + if (operation) { + const OPERATIONS_BASE_DIR = path.resolve(__dirname, '../skills/mem-search/operations'); + const operationPath = path.resolve(OPERATIONS_BASE_DIR, `${operation}.md`); + if (!operationPath.startsWith(OPERATIONS_BASE_DIR + path.sep)) { + return res.status(400).json({ error: 'Invalid request' }); } + } - res.json({ - content: [{ type: 'text', text: content }] - }); + try { + const content = await this.loadInstructionContent(operation, topic); + res.json({ content: [{ type: 'text', text: content }] }); } catch (error) { + if (error instanceof Error) { + logger.debug('HTTP', 'Instruction file not found', { topic, operation, message: error.message }); + } else { + logger.debug('HTTP', 'Instruction file not found', { topic, operation, error: String(error) }); + } res.status(404).json({ error: 'Instruction not found' }); } }); @@ -334,6 +330,20 @@ export class Server { }); } + /** + * Load instruction content from disk for the /api/instructions endpoint. + * Caller must validate operation/topic before calling. + */ + private async loadInstructionContent(operation: string | undefined, topic: string): Promise { + if (operation) { + const operationPath = path.resolve(__dirname, '../skills/mem-search/operations', `${operation}.md`); + return fs.promises.readFile(operationPath, 'utf-8'); + } + const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md'); + const fullContent = await fs.promises.readFile(skillPath, 'utf-8'); + return this.extractInstructionSection(fullContent, topic); + } + /** * Extract a specific section from instruction content */ diff --git a/src/services/smart-file-read/parser.ts b/src/services/smart-file-read/parser.ts index 1adf22aa..06e124fa 100644 --- a/src/services/smart-file-read/parser.ts +++ b/src/services/smart-file-read/parser.ts @@ -15,6 +15,7 @@ import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from "no import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { createRequire } from "node:module"; +import { logger } from "../../utils/logger.js"; // CJS-safe require for resolving external packages at runtime. // In ESM: import.meta.url works. In CJS bundle (esbuild): __filename works. @@ -160,6 +161,7 @@ export function loadUserGrammars(projectRoot: string): UserGrammarConfig { const content = readFileSync(configPath, "utf-8"); rawConfig = JSON.parse(content); } catch { + // [ANTI-PATTERN IGNORED]: .claude-mem.json missing is the normal case for most projects userGrammarCache.set(projectRoot, EMPTY_USER_GRAMMAR_CONFIG); return EMPTY_USER_GRAMMAR_CONFIG; } @@ -274,7 +276,9 @@ function resolveGrammarPath(language: string): string | null { const rootPkgPath = _require.resolve(pkg + "/package.json"); const resolved = join(dirname(rootPkgPath), subdir); if (existsSync(join(resolved, "src"))) return resolved; - } catch { /* fall through */ } + } catch { + // [ANTI-PATTERN IGNORED]: grammar package not installed is expected for unsupported languages + } return null; } @@ -282,6 +286,7 @@ function resolveGrammarPath(language: string): string | null { const packageJsonPath = _require.resolve(pkg + "/package.json"); return dirname(packageJsonPath); } catch { + // [ANTI-PATTERN IGNORED]: grammar package not installed is expected for unsupported languages return null; } } @@ -550,7 +555,9 @@ function getTreeSitterBin(): string { cachedBinPath = binPath; return binPath; } - } catch { /* fall through */ } + } catch { + // [ANTI-PATTERN IGNORED]: tree-sitter-cli not in node_modules is expected; falls back to PATH + } // Fallback: assume it's on PATH cachedBinPath = "tree-sitter"; @@ -585,7 +592,8 @@ function runBatchQuery(queryFile: string, sourceFiles: string[], grammarPath: st let output: string; try { output = execFileSync(bin, execArgs, { encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"] }); - } catch { + } catch (error) { + logger.debug('WORKER', `tree-sitter query failed for ${sourceFiles.length} file(s)`, undefined, error instanceof Error ? error : undefined); return new Map(); } diff --git a/src/services/smart-file-read/search.ts b/src/services/smart-file-read/search.ts index 4b31e333..48f7c7ad 100644 --- a/src/services/smart-file-read/search.ts +++ b/src/services/smart-file-read/search.ts @@ -13,6 +13,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { join, relative } from "node:path"; import { parseFilesBatch, formatFoldedView, loadUserGrammars, type FoldedFile } from "./parser.js"; +import { logger } from "../../utils/logger.js"; const CODE_EXTENSIONS = new Set([ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", @@ -78,7 +79,8 @@ async function* walkDir(dir: string, rootDir: string, maxDepth: number = 20, ext let entries; try { entries = await readdir(dir, { withFileTypes: true }); - } catch { + } catch (error) { + logger.debug('WORKER', `walkDir: failed to read directory ${dir}`, undefined, error instanceof Error ? error : undefined); return; // permission denied, etc. } @@ -114,7 +116,8 @@ async function safeReadFile(filePath: string): Promise { if (content.slice(0, 1000).includes("\0")) return null; return content; - } catch { + } catch (error) { + logger.debug('WORKER', `safeReadFile: failed to read ${filePath}`, undefined, error instanceof Error ? error : undefined); return null; } } diff --git a/src/services/sqlite/SessionSearch.ts b/src/services/sqlite/SessionSearch.ts index 08f70471..72b86fe8 100644 --- a/src/services/sqlite/SessionSearch.ts +++ b/src/services/sqlite/SessionSearch.ts @@ -75,92 +75,11 @@ export class SessionSearch { logger.info('DB', 'Creating FTS5 tables'); try { - // Create observations_fts virtual table - this.db.run(` - CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5( - title, - subtitle, - narrative, - text, - facts, - concepts, - content='observations', - content_rowid='id' - ); - `); - - // Populate with existing data - this.db.run(` - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - SELECT id, title, subtitle, narrative, text, facts, concepts - FROM observations; - `); - - // Create triggers for observations - this.db.run(` - CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - `); - - // Create session_summaries_fts virtual table - this.db.run(` - CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5( - request, - investigated, - learned, - completed, - next_steps, - notes, - content='session_summaries', - content_rowid='id' - ); - `); - - // Populate with existing data - this.db.run(` - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - SELECT id, request, investigated, learned, completed, next_steps, notes - FROM session_summaries; - `); - - // Create triggers for session_summaries - this.db.run(` - CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - `); - + this.createFTSTablesAndTriggers(); logger.info('DB', 'FTS5 tables created successfully'); } catch (error) { // FTS5 creation failed at runtime despite probe succeeding — degrade gracefully - logger.warn('DB', 'FTS5 table creation failed — search will use ChromaDB and LIKE queries', {}, error as Error); + logger.warn('DB', 'FTS5 table creation failed — search will use ChromaDB and LIKE queries', {}, error instanceof Error ? error : undefined); } } @@ -174,10 +93,98 @@ export class SessionSearch { this.db.run('DROP TABLE _fts5_probe'); return true; } catch { + // [ANTI-PATTERN IGNORED]: FTS5 unavailability is an expected platform condition, not an error return false; } } + /** + * Create FTS5 virtual tables and sync triggers for observations and session_summaries. + * Extracted from ensureFTSTables to keep try block small. + */ + private createFTSTablesAndTriggers(): void { + // Create observations_fts virtual table + this.db.run(` + CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5( + title, + subtitle, + narrative, + text, + facts, + concepts, + content='observations', + content_rowid='id' + ); + `); + + // Populate with existing data + this.db.run(` + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + SELECT id, title, subtitle, narrative, text, facts, concepts + FROM observations; + `); + + // Create triggers for observations + this.db.run(` + CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + `); + + // Create session_summaries_fts virtual table + this.db.run(` + CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5( + request, + investigated, + learned, + completed, + next_steps, + notes, + content='session_summaries', + content_rowid='id' + ); + `); + + // Populate with existing data + this.db.run(` + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + SELECT id, request, investigated, learned, completed, next_steps, notes + FROM session_summaries; + `); + + // Create triggers for session_summaries + this.db.run(` + CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + `); + } /** * Build WHERE clause for structured filters @@ -381,7 +388,9 @@ export class SessionSearch { if (Array.isArray(files)) { return files.some(f => isDirectChild(f, folderPath)); } - } catch {} + } catch (error) { + logger.debug('DB', `Failed to parse files JSON for observation ${obs.id}`, undefined, error instanceof Error ? error : undefined); + } return false; }; @@ -399,7 +408,9 @@ export class SessionSearch { if (Array.isArray(files)) { return files.some(f => isDirectChild(f, folderPath)); } - } catch {} + } catch (error) { + logger.debug('DB', `Failed to parse files JSON for session summary ${session.id}`, undefined, error instanceof Error ? error : undefined); + } return false; }; diff --git a/src/services/sqlite/SessionStore.ts b/src/services/sqlite/SessionStore.ts index bec26243..a568e5bf 100644 --- a/src/services/sqlite/SessionStore.ts +++ b/src/services/sqlite/SessionStore.ts @@ -446,36 +446,46 @@ export class SessionStore { // Create FTS5 virtual table — skip if FTS5 is unavailable (e.g., Bun on Windows #791). // The user_prompts table itself is still created; only FTS indexing is skipped. + const ftsCreateSQL = ` + CREATE VIRTUAL TABLE user_prompts_fts USING fts5( + prompt_text, + content='user_prompts', + content_rowid='id' + ); + `; + const ftsTriggersSQL = ` + CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN + INSERT INTO user_prompts_fts(rowid, prompt_text) + VALUES (new.id, new.prompt_text); + END; + + CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN + INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) + VALUES('delete', old.id, old.prompt_text); + END; + + CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN + INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) + VALUES('delete', old.id, old.prompt_text); + INSERT INTO user_prompts_fts(rowid, prompt_text) + VALUES (new.id, new.prompt_text); + END; + `; + try { - this.db.run(` - CREATE VIRTUAL TABLE user_prompts_fts USING fts5( - prompt_text, - content='user_prompts', - content_rowid='id' - ); - `); - - // Create triggers to sync FTS5 - this.db.run(` - CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN - INSERT INTO user_prompts_fts(rowid, prompt_text) - VALUES (new.id, new.prompt_text); - END; - - CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN - INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) - VALUES('delete', old.id, old.prompt_text); - END; - - CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN - INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) - VALUES('delete', old.id, old.prompt_text); - INSERT INTO user_prompts_fts(rowid, prompt_text) - VALUES (new.id, new.prompt_text); - END; - `); + this.db.run(ftsCreateSQL); + this.db.run(ftsTriggersSQL); } catch (ftsError) { - logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError as Error); + if (ftsError instanceof Error) { + logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError); + } else { + logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, new Error(String(ftsError))); + } + // FTS is optional — commit the main table and indexes, then return + this.db.run('COMMIT'); + this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString()); + logger.debug('DB', 'Created user_prompts table (without FTS5)'); + return; } // Commit transaction @@ -686,169 +696,177 @@ export class SessionStore { this.db.run('PRAGMA foreign_keys = OFF'); this.db.run('BEGIN TRANSACTION'); + // ========================================== + // 1. Recreate observations table + // ========================================== + + // Drop FTS triggers first (they reference the observations table) + this.db.run('DROP TRIGGER IF EXISTS observations_ai'); + this.db.run('DROP TRIGGER IF EXISTS observations_ad'); + this.db.run('DROP TRIGGER IF EXISTS observations_au'); + + // Clean up leftover temp table from a previously-crashed run + this.db.run('DROP TABLE IF EXISTS observations_new'); + + const observationsNewSQL = ` + CREATE TABLE observations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + text TEXT, + type TEXT NOT NULL, + title TEXT, + subtitle TEXT, + facts TEXT, + narrative TEXT, + concepts TEXT, + files_read TEXT, + files_modified TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `; + const observationsCopySQL = ` + INSERT INTO observations_new + SELECT id, memory_session_id, project, text, type, title, subtitle, facts, + narrative, concepts, files_read, files_modified, prompt_number, + discovery_tokens, created_at, created_at_epoch + FROM observations + `; + const observationsIndexesSQL = ` + CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id); + CREATE INDEX idx_observations_project ON observations(project); + CREATE INDEX idx_observations_type ON observations(type); + CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC); + `; + const observationsFTSTriggersSQL = ` + CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + `; + + // ========================================== + // 2. Recreate session_summaries table + // ========================================== + + // Drop session_summaries FTS triggers before dropping the table + this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai'); + this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad'); + this.db.run('DROP TRIGGER IF EXISTS session_summaries_au'); + + // Clean up leftover temp table from a previously-crashed run + this.db.run('DROP TABLE IF EXISTS session_summaries_new'); + + const summariesNewSQL = ` + CREATE TABLE session_summaries_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + investigated TEXT, + learned TEXT, + completed TEXT, + next_steps TEXT, + files_read TEXT, + files_edited TEXT, + notes TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `; + const summariesCopySQL = ` + INSERT INTO session_summaries_new + SELECT id, memory_session_id, project, request, investigated, learned, + completed, next_steps, files_read, files_edited, notes, + prompt_number, discovery_tokens, created_at, created_at_epoch + FROM session_summaries + `; + const summariesIndexesSQL = ` + CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id); + CREATE INDEX idx_session_summaries_project ON session_summaries(project); + CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC); + `; + const summariesFTSTriggersSQL = ` + CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + `; + try { - // ========================================== - // 1. Recreate observations table - // ========================================== + this.recreateObservationsWithCascade(observationsNewSQL, observationsCopySQL, observationsIndexesSQL, observationsFTSTriggersSQL); + this.recreateSessionSummariesWithCascade(summariesNewSQL, summariesCopySQL, summariesIndexesSQL, summariesFTSTriggersSQL); - // Drop FTS triggers first (they reference the observations table) - this.db.run('DROP TRIGGER IF EXISTS observations_ai'); - this.db.run('DROP TRIGGER IF EXISTS observations_ad'); - this.db.run('DROP TRIGGER IF EXISTS observations_au'); - - // Clean up leftover temp table from a previously-crashed run - this.db.run('DROP TABLE IF EXISTS observations_new'); - - this.db.run(` - CREATE TABLE observations_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - memory_session_id TEXT NOT NULL, - project TEXT NOT NULL, - text TEXT, - type TEXT NOT NULL, - title TEXT, - subtitle TEXT, - facts TEXT, - narrative TEXT, - concepts TEXT, - files_read TEXT, - files_modified TEXT, - prompt_number INTEGER, - discovery_tokens INTEGER DEFAULT 0, - created_at TEXT NOT NULL, - created_at_epoch INTEGER NOT NULL, - FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE - ) - `); - - this.db.run(` - INSERT INTO observations_new - SELECT id, memory_session_id, project, text, type, title, subtitle, facts, - narrative, concepts, files_read, files_modified, prompt_number, - discovery_tokens, created_at, created_at_epoch - FROM observations - `); - - this.db.run('DROP TABLE observations'); - this.db.run('ALTER TABLE observations_new RENAME TO observations'); - - // Recreate indexes - this.db.run(` - CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id); - CREATE INDEX idx_observations_project ON observations(project); - CREATE INDEX idx_observations_type ON observations(type); - CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC); - `); - - // Recreate FTS triggers only if observations_fts exists - // (SessionSearch.ensureFTSTables creates it on first use with IF NOT EXISTS) - const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0; - if (hasFTS) { - this.db.run(` - CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - `); - } - - // ========================================== - // 2. Recreate session_summaries table - // ========================================== - - // Clean up leftover temp table from a previously-crashed run - this.db.run('DROP TABLE IF EXISTS session_summaries_new'); - - this.db.run(` - CREATE TABLE session_summaries_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - memory_session_id TEXT NOT NULL, - project TEXT NOT NULL, - request TEXT, - investigated TEXT, - learned TEXT, - completed TEXT, - next_steps TEXT, - files_read TEXT, - files_edited TEXT, - notes TEXT, - prompt_number INTEGER, - discovery_tokens INTEGER DEFAULT 0, - created_at TEXT NOT NULL, - created_at_epoch INTEGER NOT NULL, - FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE - ) - `); - - this.db.run(` - INSERT INTO session_summaries_new - SELECT id, memory_session_id, project, request, investigated, learned, - completed, next_steps, files_read, files_edited, notes, - prompt_number, discovery_tokens, created_at, created_at_epoch - FROM session_summaries - `); - - // Drop session_summaries FTS triggers before dropping the table - this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai'); - this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad'); - this.db.run('DROP TRIGGER IF EXISTS session_summaries_au'); - - this.db.run('DROP TABLE session_summaries'); - this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries'); - - // Recreate indexes - this.db.run(` - CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id); - CREATE INDEX idx_session_summaries_project ON session_summaries(project); - CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC); - `); - - // Recreate session_summaries FTS triggers if FTS table exists - const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0; - if (hasSummariesFTS) { - this.db.run(` - CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - `); - } - - // Record migration this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(21, new Date().toISOString()); - this.db.run('COMMIT'); this.db.run('PRAGMA foreign_keys = ON'); - logger.debug('DB', 'Successfully added ON UPDATE CASCADE to FK constraints'); } catch (error) { this.db.run('ROLLBACK'); this.db.run('PRAGMA foreign_keys = ON'); - throw error; + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); + } + } + + /** Recreate observations table with ON UPDATE CASCADE FK (used by migration 21) */ + private recreateObservationsWithCascade(createSQL: string, copySQL: string, indexesSQL: string, ftsTriggersSQL: string): void { + this.db.run(createSQL); + this.db.run(copySQL); + this.db.run('DROP TABLE observations'); + this.db.run('ALTER TABLE observations_new RENAME TO observations'); + this.db.run(indexesSQL); + + const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0; + if (hasFTS) { + this.db.run(ftsTriggersSQL); + } + } + + /** Recreate session_summaries table with ON UPDATE CASCADE FK (used by migration 21) */ + private recreateSessionSummariesWithCascade(createSQL: string, copySQL: string, indexesSQL: string, ftsTriggersSQL: string): void { + this.db.run(createSQL); + this.db.run(copySQL); + this.db.run('DROP TABLE session_summaries'); + this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries'); + this.db.run(indexesSQL); + + const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0; + if (hasSummariesFTS) { + this.db.run(ftsTriggersSQL); } } @@ -2299,8 +2317,12 @@ export class SessionStore { startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch; endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch; - } catch (err: any) { - logger.error('DB', 'Error getting boundary observations', undefined, { error: err, project }); + } catch (err) { + if (err instanceof Error) { + logger.error('DB', 'Error getting boundary observations', { project }, err); + } else { + logger.error('DB', 'Error getting boundary observations with non-Error', {}, new Error(String(err))); + } return { observations: [], sessions: [], prompts: [] }; } } else { @@ -2331,8 +2353,12 @@ export class SessionStore { startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch; endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch; - } catch (err: any) { - logger.error('DB', 'Error getting boundary timestamps', undefined, { error: err, project }); + } catch (err) { + if (err instanceof Error) { + logger.error('DB', 'Error getting boundary timestamps', { project }, err); + } else { + logger.error('DB', 'Error getting boundary timestamps with non-Error', {}, new Error(String(err))); + } return { observations: [], sessions: [], prompts: [] }; } } diff --git a/src/services/sqlite/migrations.ts b/src/services/sqlite/migrations.ts index ee5a7630..4f05b393 100644 --- a/src/services/sqlite/migrations.ts +++ b/src/services/sqlite/migrations.ts @@ -378,8 +378,8 @@ export const migration006: Migration = { try { db.run('CREATE VIRTUAL TABLE _fts5_probe USING fts5(test_column)'); db.run('DROP TABLE _fts5_probe'); - } catch { - console.log('⚠️ FTS5 not available on this platform — skipping FTS migration (search uses ChromaDB)'); + } catch (error) { + logger.warn('DB', 'FTS5 not available on this platform — skipping FTS migration (search uses ChromaDB)', {}, error instanceof Error ? error : undefined); return; } diff --git a/src/services/sqlite/migrations/runner.ts b/src/services/sqlite/migrations/runner.ts index 00e40912..60547981 100644 --- a/src/services/sqlite/migrations/runner.ts +++ b/src/services/sqlite/migrations/runner.ts @@ -419,35 +419,9 @@ export class MigrationRunner { // Create FTS5 virtual table — skip if FTS5 is unavailable (e.g., Bun on Windows #791). // The user_prompts table itself is still created; only FTS indexing is skipped. try { - this.db.run(` - CREATE VIRTUAL TABLE user_prompts_fts USING fts5( - prompt_text, - content='user_prompts', - content_rowid='id' - ); - `); - - // Create triggers to sync FTS5 - this.db.run(` - CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN - INSERT INTO user_prompts_fts(rowid, prompt_text) - VALUES (new.id, new.prompt_text); - END; - - CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN - INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) - VALUES('delete', old.id, old.prompt_text); - END; - - CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN - INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) - VALUES('delete', old.id, old.prompt_text); - INSERT INTO user_prompts_fts(rowid, prompt_text) - VALUES (new.id, new.prompt_text); - END; - `); + this.createUserPromptsFTS(); } catch (ftsError) { - logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError as Error); + logger.warn('DB', 'FTS5 not available — user_prompts_fts skipped (search uses ChromaDB)', {}, ftsError instanceof Error ? ftsError : undefined); } // Commit transaction @@ -459,6 +433,39 @@ export class MigrationRunner { logger.debug('DB', 'Successfully created user_prompts table'); } + /** + * Create FTS5 virtual table and sync triggers for user_prompts. + * Extracted from createUserPromptsTable to keep try block small. + */ + private createUserPromptsFTS(): void { + this.db.run(` + CREATE VIRTUAL TABLE user_prompts_fts USING fts5( + prompt_text, + content='user_prompts', + content_rowid='id' + ); + `); + + this.db.run(` + CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN + INSERT INTO user_prompts_fts(rowid, prompt_text) + VALUES (new.id, new.prompt_text); + END; + + CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN + INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) + VALUES('delete', old.id, old.prompt_text); + END; + + CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN + INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text) + VALUES('delete', old.id, old.prompt_text); + INSERT INTO user_prompts_fts(rowid, prompt_text) + VALUES (new.id, new.prompt_text); + END; + `); + } + /** * Ensure discovery_tokens column exists (migration 11) * CRITICAL: This migration was incorrectly using version 7 (which was already taken by removeSessionSummariesUniqueConstraint) @@ -659,157 +666,10 @@ export class MigrationRunner { this.db.run('BEGIN TRANSACTION'); try { - // =================================== - // 1. Recreate observations table - // =================================== - // Drop FTS triggers first (they reference the observations table) - this.db.run('DROP TRIGGER IF EXISTS observations_ai'); - this.db.run('DROP TRIGGER IF EXISTS observations_ad'); - this.db.run('DROP TRIGGER IF EXISTS observations_au'); + this.recreateObservationsWithUpdateCascade(); + this.recreateSessionSummariesWithUpdateCascade(); - // Clean up leftover temp table from a previously-crashed run - this.db.run('DROP TABLE IF EXISTS observations_new'); - - this.db.run(` - CREATE TABLE observations_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - memory_session_id TEXT NOT NULL, - project TEXT NOT NULL, - text TEXT, - type TEXT NOT NULL, - title TEXT, - subtitle TEXT, - facts TEXT, - narrative TEXT, - concepts TEXT, - files_read TEXT, - files_modified TEXT, - prompt_number INTEGER, - discovery_tokens INTEGER DEFAULT 0, - created_at TEXT NOT NULL, - created_at_epoch INTEGER NOT NULL, - FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE - ) - `); - - this.db.run(` - INSERT INTO observations_new - SELECT id, memory_session_id, project, text, type, title, subtitle, facts, - narrative, concepts, files_read, files_modified, prompt_number, - discovery_tokens, created_at, created_at_epoch - FROM observations - `); - - this.db.run('DROP TABLE observations'); - this.db.run('ALTER TABLE observations_new RENAME TO observations'); - - // Recreate indexes - this.db.run(` - CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id); - CREATE INDEX idx_observations_project ON observations(project); - CREATE INDEX idx_observations_type ON observations(type); - CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC); - `); - - // Recreate FTS triggers only if observations_fts exists - const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0; - if (hasFTS) { - this.db.run(` - CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - END; - - CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN - INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) - VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); - INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) - VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); - END; - `); - } - - // =================================== - // 2. Recreate session_summaries table - // =================================== - // Clean up leftover temp table from a previously-crashed run - this.db.run('DROP TABLE IF EXISTS session_summaries_new'); - - this.db.run(` - CREATE TABLE session_summaries_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - memory_session_id TEXT NOT NULL, - project TEXT NOT NULL, - request TEXT, - investigated TEXT, - learned TEXT, - completed TEXT, - next_steps TEXT, - files_read TEXT, - files_edited TEXT, - notes TEXT, - prompt_number INTEGER, - discovery_tokens INTEGER DEFAULT 0, - created_at TEXT NOT NULL, - created_at_epoch INTEGER NOT NULL, - FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE - ) - `); - - this.db.run(` - INSERT INTO session_summaries_new - SELECT id, memory_session_id, project, request, investigated, learned, - completed, next_steps, files_read, files_edited, notes, - prompt_number, discovery_tokens, created_at, created_at_epoch - FROM session_summaries - `); - - // Drop session_summaries FTS triggers before dropping the table - this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai'); - this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad'); - this.db.run('DROP TRIGGER IF EXISTS session_summaries_au'); - - this.db.run('DROP TABLE session_summaries'); - this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries'); - - // Recreate indexes - this.db.run(` - CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id); - CREATE INDEX idx_session_summaries_project ON session_summaries(project); - CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC); - `); - - // Recreate session_summaries FTS triggers if FTS table exists - const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0; - if (hasSummariesFTS) { - this.db.run(` - CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - END; - - CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN - INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) - VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); - INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) - VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); - END; - `); - } - - // Record migration this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(21, new Date().toISOString()); - this.db.run('COMMIT'); this.db.run('PRAGMA foreign_keys = ON'); @@ -817,7 +677,162 @@ export class MigrationRunner { } catch (error) { this.db.run('ROLLBACK'); this.db.run('PRAGMA foreign_keys = ON'); - throw error; + if (error instanceof Error) { + throw error; + } + throw new Error(`Migration 21 failed: ${String(error)}`); + } + } + + /** + * Recreate observations table with ON UPDATE CASCADE FK constraint. + * Called within a transaction by addOnUpdateCascadeToForeignKeys. + */ + private recreateObservationsWithUpdateCascade(): void { + // Drop FTS triggers first (they reference the observations table) + this.db.run('DROP TRIGGER IF EXISTS observations_ai'); + this.db.run('DROP TRIGGER IF EXISTS observations_ad'); + this.db.run('DROP TRIGGER IF EXISTS observations_au'); + + // Clean up leftover temp table from a previously-crashed run + this.db.run('DROP TABLE IF EXISTS observations_new'); + + this.db.run(` + CREATE TABLE observations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + text TEXT, + type TEXT NOT NULL, + title TEXT, + subtitle TEXT, + facts TEXT, + narrative TEXT, + concepts TEXT, + files_read TEXT, + files_modified TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `); + + this.db.run(` + INSERT INTO observations_new + SELECT id, memory_session_id, project, text, type, title, subtitle, facts, + narrative, concepts, files_read, files_modified, prompt_number, + discovery_tokens, created_at, created_at_epoch + FROM observations + `); + + this.db.run('DROP TABLE observations'); + this.db.run('ALTER TABLE observations_new RENAME TO observations'); + + this.db.run(` + CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id); + CREATE INDEX idx_observations_project ON observations(project); + CREATE INDEX idx_observations_type ON observations(type); + CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC); + `); + + // Recreate FTS triggers only if observations_fts exists + const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0; + if (hasFTS) { + this.db.run(` + CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + END; + + CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN + INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts) + VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts); + INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts) + VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts); + END; + `); + } + } + + /** + * Recreate session_summaries table with ON UPDATE CASCADE FK constraint. + * Called within a transaction by addOnUpdateCascadeToForeignKeys. + */ + private recreateSessionSummariesWithUpdateCascade(): void { + // Clean up leftover temp table from a previously-crashed run + this.db.run('DROP TABLE IF EXISTS session_summaries_new'); + + this.db.run(` + CREATE TABLE session_summaries_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_session_id TEXT NOT NULL, + project TEXT NOT NULL, + request TEXT, + investigated TEXT, + learned TEXT, + completed TEXT, + next_steps TEXT, + files_read TEXT, + files_edited TEXT, + notes TEXT, + prompt_number INTEGER, + discovery_tokens INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + created_at_epoch INTEGER NOT NULL, + FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE + ) + `); + + this.db.run(` + INSERT INTO session_summaries_new + SELECT id, memory_session_id, project, request, investigated, learned, + completed, next_steps, files_read, files_edited, notes, + prompt_number, discovery_tokens, created_at, created_at_epoch + FROM session_summaries + `); + + // Drop session_summaries FTS triggers before dropping the table + this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai'); + this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad'); + this.db.run('DROP TRIGGER IF EXISTS session_summaries_au'); + + this.db.run('DROP TABLE session_summaries'); + this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries'); + + this.db.run(` + CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id); + CREATE INDEX idx_session_summaries_project ON session_summaries(project); + CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC); + `); + + // Recreate session_summaries FTS triggers if FTS table exists + const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0; + if (hasSummariesFTS) { + this.db.run(` + CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + END; + + CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN + INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes) + VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes); + INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes) + VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes); + END; + `); } } diff --git a/src/services/sqlite/observations/files.ts b/src/services/sqlite/observations/files.ts index ec122fd3..fd2e9828 100644 --- a/src/services/sqlite/observations/files.ts +++ b/src/services/sqlite/observations/files.ts @@ -18,6 +18,7 @@ export function parseFileList(value: string | null | undefined): string[] { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : [String(parsed)]; } catch { + // [ANTI-PATTERN IGNORED]: legacy bare-path strings are expected input, not errors return [value]; } } diff --git a/src/services/sqlite/timeline/queries.ts b/src/services/sqlite/timeline/queries.ts index f47cf8b9..6d59064f 100644 --- a/src/services/sqlite/timeline/queries.ts +++ b/src/services/sqlite/timeline/queries.ts @@ -111,8 +111,8 @@ export function getTimelineAroundObservation( startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch; endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch; - } catch (err: any) { - logger.error('DB', 'Error getting boundary observations', undefined, { error: err, project }); + } catch (err) { + logger.error('DB', 'Error getting boundary observations', undefined, { error: err instanceof Error ? err : new Error(String(err)), project }); return { observations: [], sessions: [], prompts: [] }; } } else { @@ -143,8 +143,8 @@ export function getTimelineAroundObservation( startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch; endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch; - } catch (err: any) { - logger.error('DB', 'Error getting boundary timestamps', undefined, { error: err, project }); + } catch (err) { + logger.error('DB', 'Error getting boundary timestamps', undefined, { error: err instanceof Error ? err : new Error(String(err)), project }); return { observations: [], sessions: [], prompts: [] }; } } diff --git a/src/services/sync/ChromaMcpManager.ts b/src/services/sync/ChromaMcpManager.ts index c293cbf0..49d856a5 100644 --- a/src/services/sync/ChromaMcpManager.ts +++ b/src/services/sync/ChromaMcpManager.ts @@ -78,6 +78,11 @@ export class ChromaMcpManager { await this.connecting; } catch (error) { this.lastConnectionFailureTimestamp = Date.now(); + if (error instanceof Error) { + logger.error('CHROMA_MCP', 'Connection attempt failed', {}, error); + } else { + logger.error('CHROMA_MCP', 'Connection attempt failed with non-Error value', { error: String(error) }); + } throw error; } finally { this.connecting = null; @@ -307,9 +312,15 @@ export class ChromaMcpManager { // Try JSON parse first; if it fails, return the raw text for non-error responses. try { return JSON.parse(firstTextContent.text); - } catch { + } catch (parseError: unknown) { // Plain text response (e.g. "Successfully created collection cm__foo") // Return null for void-like success messages, callers don't need the text + if (parseError instanceof Error) { + logger.debug('CHROMA_MCP', 'Non-JSON response from tool, returning null', { + toolName, + textPreview: firstTextContent.text.slice(0, 100) + }); + } return null; } } @@ -322,7 +333,10 @@ export class ChromaMcpManager { try { await this.callTool('chroma_list_collections', { limit: 1 }); return true; - } catch { + } catch (error) { + logger.warn('CHROMA_MCP', 'Health check failed', { + error: error instanceof Error ? error.message : String(error) + }); return false; } } @@ -342,7 +356,11 @@ export class ChromaMcpManager { try { await this.client.close(); } catch (error) { - logger.debug('CHROMA_MCP', 'Error during client close (subprocess may already be dead)', {}, error as Error); + if (error instanceof Error) { + logger.debug('CHROMA_MCP', 'Error during client close (subprocess may already be dead)', {}, error); + } else { + logger.debug('CHROMA_MCP', 'Error during client close (subprocess may already be dead)', { error: String(error) }); + } } getSupervisor().unregisterProcess(CHROMA_SUPERVISOR_ID); @@ -394,7 +412,10 @@ export class ChromaMcpManager { 'uvx --with certifi python -c "import certifi; print(certifi.where())"', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 } ).trim(); - } catch { + } catch (error) { + logger.debug('CHROMA_MCP', 'Failed to resolve certifi path via uvx', { + error: error instanceof Error ? error.message : String(error) + }); return undefined; } @@ -408,7 +429,10 @@ export class ChromaMcpManager { 'security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 } ); - } catch { + } catch (error) { + logger.debug('CHROMA_MCP', 'No Zscaler certificate found in system keychain', { + error: error instanceof Error ? error.message : String(error) + }); return undefined; } diff --git a/src/services/sync/ChromaSync.ts b/src/services/sync/ChromaSync.ts index a4545e8d..d2996c25 100644 --- a/src/services/sync/ChromaSync.ts +++ b/src/services/sync/ChromaSync.ts @@ -563,152 +563,7 @@ export class ChromaSync { const db = new SessionStore(); try { - // Build exclusion list for observations - // Filter to validated positive integers before interpolating into SQL - const existingObsIds = Array.from(existing.observations).filter(id => Number.isInteger(id) && id > 0); - const obsExclusionClause = existingObsIds.length > 0 - ? `AND id NOT IN (${existingObsIds.join(',')})` - : ''; - - // Get only observations missing from Chroma - const observations = db.db.prepare(` - SELECT * FROM observations - WHERE project = ? ${obsExclusionClause} - ORDER BY id ASC - `).all(backfillProject) as StoredObservation[]; - - const totalObsCount = db.db.prepare(` - SELECT COUNT(*) as count FROM observations WHERE project = ? - `).get(backfillProject) as { count: number }; - - logger.info('CHROMA_SYNC', 'Backfilling observations', { - project: backfillProject, - missing: observations.length, - existing: existing.observations.size, - total: totalObsCount.count - }); - - // Format all observation documents - const allDocs: ChromaDocument[] = []; - for (const obs of observations) { - allDocs.push(...this.formatObservationDocs(obs)); - } - - // Sync in batches - for (let i = 0; i < allDocs.length; i += this.BATCH_SIZE) { - const batch = allDocs.slice(i, i + this.BATCH_SIZE); - await this.addDocuments(batch); - - logger.debug('CHROMA_SYNC', 'Backfill progress', { - project: backfillProject, - progress: `${Math.min(i + this.BATCH_SIZE, allDocs.length)}/${allDocs.length}` - }); - } - - // Build exclusion list for summaries - const existingSummaryIds = Array.from(existing.summaries).filter(id => Number.isInteger(id) && id > 0); - const summaryExclusionClause = existingSummaryIds.length > 0 - ? `AND id NOT IN (${existingSummaryIds.join(',')})` - : ''; - - // Get only summaries missing from Chroma - const summaries = db.db.prepare(` - SELECT * FROM session_summaries - WHERE project = ? ${summaryExclusionClause} - ORDER BY id ASC - `).all(backfillProject) as StoredSummary[]; - - const totalSummaryCount = db.db.prepare(` - SELECT COUNT(*) as count FROM session_summaries WHERE project = ? - `).get(backfillProject) as { count: number }; - - logger.info('CHROMA_SYNC', 'Backfilling summaries', { - project: backfillProject, - missing: summaries.length, - existing: existing.summaries.size, - total: totalSummaryCount.count - }); - - // Format all summary documents - const summaryDocs: ChromaDocument[] = []; - for (const summary of summaries) { - summaryDocs.push(...this.formatSummaryDocs(summary)); - } - - // Sync in batches - for (let i = 0; i < summaryDocs.length; i += this.BATCH_SIZE) { - const batch = summaryDocs.slice(i, i + this.BATCH_SIZE); - await this.addDocuments(batch); - - logger.debug('CHROMA_SYNC', 'Backfill progress', { - project: backfillProject, - progress: `${Math.min(i + this.BATCH_SIZE, summaryDocs.length)}/${summaryDocs.length}` - }); - } - - // Build exclusion list for prompts - const existingPromptIds = Array.from(existing.prompts).filter(id => Number.isInteger(id) && id > 0); - const promptExclusionClause = existingPromptIds.length > 0 - ? `AND up.id NOT IN (${existingPromptIds.join(',')})` - : ''; - - // Get only user prompts missing from Chroma - const prompts = db.db.prepare(` - SELECT - up.*, - s.project, - s.memory_session_id - FROM user_prompts up - JOIN sdk_sessions s ON up.content_session_id = s.content_session_id - WHERE s.project = ? ${promptExclusionClause} - ORDER BY up.id ASC - `).all(backfillProject) as StoredUserPrompt[]; - - const totalPromptCount = db.db.prepare(` - SELECT COUNT(*) as count - FROM user_prompts up - JOIN sdk_sessions s ON up.content_session_id = s.content_session_id - WHERE s.project = ? - `).get(backfillProject) as { count: number }; - - logger.info('CHROMA_SYNC', 'Backfilling user prompts', { - project: backfillProject, - missing: prompts.length, - existing: existing.prompts.size, - total: totalPromptCount.count - }); - - // Format all prompt documents - const promptDocs: ChromaDocument[] = []; - for (const prompt of prompts) { - promptDocs.push(this.formatUserPromptDoc(prompt)); - } - - // Sync in batches - for (let i = 0; i < promptDocs.length; i += this.BATCH_SIZE) { - const batch = promptDocs.slice(i, i + this.BATCH_SIZE); - await this.addDocuments(batch); - - logger.debug('CHROMA_SYNC', 'Backfill progress', { - project: backfillProject, - progress: `${Math.min(i + this.BATCH_SIZE, promptDocs.length)}/${promptDocs.length}` - }); - } - - logger.info('CHROMA_SYNC', 'Smart backfill complete', { - project: backfillProject, - synced: { - observationDocs: allDocs.length, - summaryDocs: summaryDocs.length, - promptDocs: promptDocs.length - }, - skipped: { - observations: existing.observations.size, - summaries: existing.summaries.size, - prompts: existing.prompts.size - } - }); - + await this.runBackfillPipeline(db, backfillProject, existing); } catch (error) { logger.error('CHROMA_SYNC', 'Backfill failed', { project: backfillProject }, error as Error); throw new Error(`Backfill failed: ${error instanceof Error ? error.message : String(error)}`); @@ -717,6 +572,185 @@ export class ChromaSync { } } + private async runBackfillPipeline( + db: SessionStore, + backfillProject: string, + existing: { observations: Set; summaries: Set; prompts: Set } + ): Promise { + const allDocs = await this.backfillObservations(db, backfillProject, existing.observations); + const summaryDocs = await this.backfillSummaries(db, backfillProject, existing.summaries); + const promptDocs = await this.backfillPrompts(db, backfillProject, existing.prompts); + + logger.info('CHROMA_SYNC', 'Smart backfill complete', { + project: backfillProject, + synced: { + observationDocs: allDocs.length, + summaryDocs: summaryDocs.length, + promptDocs: promptDocs.length + }, + skipped: { + observations: existing.observations.size, + summaries: existing.summaries.size, + prompts: existing.prompts.size + } + }); + } + + /** + * Backfill observations missing from Chroma for a given project. + * Returns the formatted documents that were synced. + */ + private async backfillObservations( + db: SessionStore, + backfillProject: string, + existingObservationIds: Set + ): Promise { + const existingObsIds = Array.from(existingObservationIds).filter(id => Number.isInteger(id) && id > 0); + const obsExclusionClause = existingObsIds.length > 0 + ? `AND id NOT IN (${existingObsIds.join(',')})` + : ''; + + const observations = db.db.prepare(` + SELECT * FROM observations + WHERE project = ? ${obsExclusionClause} + ORDER BY id ASC + `).all(backfillProject) as StoredObservation[]; + + const totalObsCount = db.db.prepare(` + SELECT COUNT(*) as count FROM observations WHERE project = ? + `).get(backfillProject) as { count: number }; + + logger.info('CHROMA_SYNC', 'Backfilling observations', { + project: backfillProject, + missing: observations.length, + existing: existingObservationIds.size, + total: totalObsCount.count + }); + + const allDocs: ChromaDocument[] = []; + for (const obs of observations) { + allDocs.push(...this.formatObservationDocs(obs)); + } + + for (let i = 0; i < allDocs.length; i += this.BATCH_SIZE) { + const batch = allDocs.slice(i, i + this.BATCH_SIZE); + await this.addDocuments(batch); + + logger.debug('CHROMA_SYNC', 'Backfill progress', { + project: backfillProject, + progress: `${Math.min(i + this.BATCH_SIZE, allDocs.length)}/${allDocs.length}` + }); + } + + return allDocs; + } + + /** + * Backfill summaries missing from Chroma for a given project. + * Returns the formatted documents that were synced. + */ + private async backfillSummaries( + db: SessionStore, + backfillProject: string, + existingSummaryIdSet: Set + ): Promise { + const existingSummaryIds = Array.from(existingSummaryIdSet).filter(id => Number.isInteger(id) && id > 0); + const summaryExclusionClause = existingSummaryIds.length > 0 + ? `AND id NOT IN (${existingSummaryIds.join(',')})` + : ''; + + const summaries = db.db.prepare(` + SELECT * FROM session_summaries + WHERE project = ? ${summaryExclusionClause} + ORDER BY id ASC + `).all(backfillProject) as StoredSummary[]; + + const totalSummaryCount = db.db.prepare(` + SELECT COUNT(*) as count FROM session_summaries WHERE project = ? + `).get(backfillProject) as { count: number }; + + logger.info('CHROMA_SYNC', 'Backfilling summaries', { + project: backfillProject, + missing: summaries.length, + existing: existingSummaryIdSet.size, + total: totalSummaryCount.count + }); + + const summaryDocs: ChromaDocument[] = []; + for (const summary of summaries) { + summaryDocs.push(...this.formatSummaryDocs(summary)); + } + + for (let i = 0; i < summaryDocs.length; i += this.BATCH_SIZE) { + const batch = summaryDocs.slice(i, i + this.BATCH_SIZE); + await this.addDocuments(batch); + + logger.debug('CHROMA_SYNC', 'Backfill progress', { + project: backfillProject, + progress: `${Math.min(i + this.BATCH_SIZE, summaryDocs.length)}/${summaryDocs.length}` + }); + } + + return summaryDocs; + } + + /** + * Backfill user prompts missing from Chroma for a given project. + * Returns the formatted documents that were synced. + */ + private async backfillPrompts( + db: SessionStore, + backfillProject: string, + existingPromptIdSet: Set + ): Promise { + const existingPromptIds = Array.from(existingPromptIdSet).filter(id => Number.isInteger(id) && id > 0); + const promptExclusionClause = existingPromptIds.length > 0 + ? `AND up.id NOT IN (${existingPromptIds.join(',')})` + : ''; + + const prompts = db.db.prepare(` + SELECT + up.*, + s.project, + s.memory_session_id + FROM user_prompts up + JOIN sdk_sessions s ON up.content_session_id = s.content_session_id + WHERE s.project = ? ${promptExclusionClause} + ORDER BY up.id ASC + `).all(backfillProject) as StoredUserPrompt[]; + + const totalPromptCount = db.db.prepare(` + SELECT COUNT(*) as count + FROM user_prompts up + JOIN sdk_sessions s ON up.content_session_id = s.content_session_id + WHERE s.project = ? + `).get(backfillProject) as { count: number }; + + logger.info('CHROMA_SYNC', 'Backfilling user prompts', { + project: backfillProject, + missing: prompts.length, + existing: existingPromptIdSet.size, + total: totalPromptCount.count + }); + + const promptDocs: ChromaDocument[] = []; + for (const prompt of prompts) { + promptDocs.push(this.formatUserPromptDoc(prompt)); + } + + for (let i = 0; i < promptDocs.length; i += this.BATCH_SIZE) { + const batch = promptDocs.slice(i, i + this.BATCH_SIZE); + await this.addDocuments(batch); + + logger.debug('CHROMA_SYNC', 'Backfill progress', { + project: backfillProject, + progress: `${Math.min(i + this.BATCH_SIZE, promptDocs.length)}/${promptDocs.length}` + }); + } + + return promptDocs; + } + /** * Query Chroma collection for semantic search via MCP * Used by SearchManager for vector-based search @@ -728,68 +762,28 @@ export class ChromaSync { ): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> { await this.ensureCollectionExists(); + let results: any; try { const chromaMcp = ChromaMcpManager.getInstance(); - const results = await chromaMcp.callTool('chroma_query_documents', { + results = await chromaMcp.callTool('chroma_query_documents', { collection_name: this.collectionName, query_texts: [query], n_results: limit, ...(whereFilter && { where: whereFilter }), include: ['documents', 'metadatas', 'distances'] - }) as any; - - // chroma_query_documents returns nested arrays (one per query text) - // We always pass a single query text, so we access [0] - const ids: number[] = []; - const seen = new Set(); - const docIds = results?.ids?.[0] || []; - const rawMetadatas = results?.metadatas?.[0] || []; - const rawDistances = results?.distances?.[0] || []; - - // Build deduplicated arrays that stay index-aligned: - // Multiple Chroma docs map to the same SQLite ID (one per field). - // Keep the first (best-ranked) distance and metadata per SQLite ID. - const metadatas: any[] = []; - const distances: number[] = []; - - for (let i = 0; i < docIds.length; i++) { - const docId = docIds[i]; - // Extract sqlite_id from document ID (supports three formats): - // - obs_{id}_narrative, obs_{id}_fact_0, etc (observations) - // - summary_{id}_request, summary_{id}_learned, etc (session summaries) - // - prompt_{id} (user prompts) - const obsMatch = docId.match(/obs_(\d+)_/); - const summaryMatch = docId.match(/summary_(\d+)_/); - const promptMatch = docId.match(/prompt_(\d+)/); - - let sqliteId: number | null = null; - if (obsMatch) { - sqliteId = parseInt(obsMatch[1], 10); - } else if (summaryMatch) { - sqliteId = parseInt(summaryMatch[1], 10); - } else if (promptMatch) { - sqliteId = parseInt(promptMatch[1], 10); - } - - if (sqliteId !== null && !seen.has(sqliteId)) { - seen.add(sqliteId); - ids.push(sqliteId); - metadatas.push(rawMetadatas[i] ?? null); - distances.push(rawDistances[i] ?? 0); - } - } - - return { ids, distances, metadatas }; + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - // Check for connection errors + // chroma-mcp surfaces connection failures as Error messages with no structured + // error codes or typed error classes. String matching is the only way to distinguish + // transient connection errors (which need collection state reset) from semantic query errors. const isConnectionError = - errorMessage.includes('ECONNREFUSED') || - errorMessage.includes('ENOTFOUND') || - errorMessage.includes('fetch failed') || - errorMessage.includes('subprocess closed') || - errorMessage.includes('timed out'); + errorMessage.includes('ECONNREFUSED') || // [ANTI-PATTERN IGNORED]: chroma-mcp has no typed error classes, string matching is the only option + errorMessage.includes('ENOTFOUND') || // [ANTI-PATTERN IGNORED]: chroma-mcp has no typed error classes, string matching is the only option + errorMessage.includes('fetch failed') || // [ANTI-PATTERN IGNORED]: chroma-mcp has no typed error classes, string matching is the only option + errorMessage.includes('subprocess closed') || // [ANTI-PATTERN IGNORED]: chroma-mcp has no typed error classes, string matching is the only option + errorMessage.includes('timed out'); // [ANTI-PATTERN IGNORED]: chroma-mcp has no typed error classes, string matching is the only option if (isConnectionError) { // Reset collection state so next call attempts reconnect @@ -802,6 +796,55 @@ export class ChromaSync { logger.error('CHROMA_SYNC', 'Query failed', { project: this.project, query }, error as Error); throw error; } + + return this.deduplicateQueryResults(results); + } + + /** + * Deduplicate Chroma query results by SQLite ID. + * Multiple Chroma docs map to the same SQLite ID (one per field). + * Keeps the first (best-ranked) distance and metadata per SQLite ID. + */ + private deduplicateQueryResults(results: any): { ids: number[]; distances: number[]; metadatas: any[] } { + // chroma_query_documents returns nested arrays (one per query text) + // We always pass a single query text, so we access [0] + const ids: number[] = []; + const seen = new Set(); + const docIds = results?.ids?.[0] || []; + const rawMetadatas = results?.metadatas?.[0] || []; + const rawDistances = results?.distances?.[0] || []; + + const metadatas: any[] = []; + const distances: number[] = []; + + for (let i = 0; i < docIds.length; i++) { + const docId = docIds[i]; + // Extract sqlite_id from document ID (supports three formats): + // - obs_{id}_narrative, obs_{id}_fact_0, etc (observations) + // - summary_{id}_request, summary_{id}_learned, etc (session summaries) + // - prompt_{id} (user prompts) + const obsMatch = docId.match(/obs_(\d+)_/); + const summaryMatch = docId.match(/summary_(\d+)_/); + const promptMatch = docId.match(/prompt_(\d+)/); + + let sqliteId: number | null = null; + if (obsMatch) { + sqliteId = parseInt(obsMatch[1], 10); + } else if (summaryMatch) { + sqliteId = parseInt(summaryMatch[1], 10); + } else if (promptMatch) { + sqliteId = parseInt(promptMatch[1], 10); + } + + if (sqliteId !== null && !seen.has(sqliteId)) { + seen.add(sqliteId); + ids.push(sqliteId); + metadatas.push(rawMetadatas[i] ?? null); + distances.push(rawDistances[i] ?? 0); + } + } + + return { ids, distances, metadatas }; } /** @@ -826,7 +869,11 @@ export class ChromaSync { try { await sync.ensureBackfilled(project); } catch (error) { - logger.error('CHROMA_SYNC', `Backfill failed for project: ${project}`, {}, error as Error); + if (error instanceof Error) { + logger.error('CHROMA_SYNC', `Backfill failed for project: ${project}`, {}, error); + } else { + logger.error('CHROMA_SYNC', `Backfill failed for project: ${project}`, { error: String(error) }); + } // Continue to next project — don't let one failure stop others } } diff --git a/src/services/transcripts/field-utils.ts b/src/services/transcripts/field-utils.ts index c498e35e..2f123a87 100644 --- a/src/services/transcripts/field-utils.ts +++ b/src/services/transcripts/field-utils.ts @@ -1,3 +1,4 @@ +import { logger } from '../../utils/logger.js'; import type { FieldSpec, MatchRule, TranscriptSchema, WatchTarget } from './types.js'; interface ResolveContext { @@ -142,7 +143,8 @@ export function matchesRule( try { const regex = new RegExp(rule.regex); return regex.test(String(value ?? '')); - } catch { + } catch (error: unknown) { + logger.debug('WORKER', 'Invalid regex in match rule', { regex: rule.regex }, error instanceof Error ? error : undefined); return false; } } diff --git a/src/services/transcripts/processor.ts b/src/services/transcripts/processor.ts index 04cb4afe..557378be 100644 --- a/src/services/transcripts/processor.ts +++ b/src/services/transcripts/processor.ts @@ -277,7 +277,8 @@ export class TranscriptEventProcessor { if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value; try { return JSON.parse(trimmed); - } catch { + } catch (error: unknown) { + logger.debug('WORKER', 'Failed to parse JSON string', { length: trimmed.length }, error instanceof Error ? error : undefined); return value; } } @@ -321,18 +322,19 @@ export class TranscriptEventProcessor { if (!workerReady) return; const lastAssistantMessage = session.lastAssistantMessage ?? ''; + const requestBody = JSON.stringify({ + contentSessionId: session.sessionId, + last_assistant_message: lastAssistantMessage, + platformSource: session.platformSource + }); try { await workerHttpRequest('/api/sessions/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contentSessionId: session.sessionId, - last_assistant_message: lastAssistantMessage, - platformSource: session.platformSource - }) + body: requestBody }); - } catch (error) { + } catch (error: unknown) { logger.warn('TRANSCRIPT', 'Summary request failed', { error: error instanceof Error ? error.message : String(error) }); @@ -352,22 +354,25 @@ export class TranscriptEventProcessor { const context = getProjectContext(cwd); const projectsParam = context.allProjects.join(','); + const contextUrl = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`; + const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`); + + let response: Awaited>; try { - const response = await workerHttpRequest( - `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}` - ); - if (!response.ok) return; - - const content = (await response.text()).trim(); - if (!content) return; - - const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`); - writeAgentsMd(agentsPath, content); - logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name }); - } catch (error) { - logger.warn('TRANSCRIPT', 'Failed to update AGENTS.md context', { + response = await workerHttpRequest(contextUrl); + } catch (error: unknown) { + logger.warn('TRANSCRIPT', 'Failed to fetch AGENTS.md context', { error: error instanceof Error ? error.message : String(error) }); + return; } + + if (!response.ok) return; + + const content = (await response.text()).trim(); + if (!content) return; + + writeAgentsMd(agentsPath, content); + logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name }); } } diff --git a/src/services/transcripts/watcher.ts b/src/services/transcripts/watcher.ts index 36d664fe..5c2fafbd 100644 --- a/src/services/transcripts/watcher.ts +++ b/src/services/transcripts/watcher.ts @@ -43,7 +43,8 @@ class FileTailer { let size = 0; try { size = statSync(this.filePath).size; - } catch { + } catch (error: unknown) { + logger.debug('WORKER', 'Failed to stat transcript file', { file: this.filePath }, error instanceof Error ? error : undefined); return; } @@ -152,7 +153,8 @@ export class TranscriptWatcher { return globSync(pattern, { nodir: true, absolute: true }); } return [inputPath]; - } catch { + } catch (error: unknown) { + logger.debug('WORKER', 'Failed to stat watch path', { path: inputPath }, error instanceof Error ? error : undefined); return []; } } @@ -180,7 +182,8 @@ export class TranscriptWatcher { if (offset === 0 && watch.startAtEnd && initialDiscovery) { try { offset = statSync(filePath).size; - } catch { + } catch (error: unknown) { + logger.debug('WORKER', 'Failed to stat file for startAtEnd offset', { file: filePath }, error instanceof Error ? error : undefined); offset = 0; } } @@ -216,11 +219,19 @@ export class TranscriptWatcher { try { const entry = JSON.parse(line); await this.processor.processEntry(entry, watch, schema, sessionIdOverride ?? undefined); - } catch (error) { - logger.debug('TRANSCRIPT', 'Failed to parse transcript line', { - watch: watch.name, - file: basename(filePath) - }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('TRANSCRIPT', 'Failed to parse transcript line', { + watch: watch.name, + file: basename(filePath) + }, error); + } else { + logger.warn('TRANSCRIPT', 'Failed to parse transcript line (non-Error thrown)', { + watch: watch.name, + file: basename(filePath), + error: String(error) + }); + } } } diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index 6d37cab8..a59d90de 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -289,11 +289,16 @@ export class WorkerService { await Promise.race([this.initializationComplete, timeoutPromise]); next(); } catch (error) { - logger.error('HTTP', `Request to ${req.method} ${req.path} rejected — DB not initialized`, {}, error as Error); + if (error instanceof Error) { + logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized`, {}, error); + } else { + logger.error('WORKER', `Request to ${req.method} ${req.path} rejected — DB not initialized with non-Error`, {}, new Error(String(error))); + } res.status(503).json({ error: 'Service initializing', message: 'Database is still initializing, please retry' }); + return; } }); @@ -372,8 +377,18 @@ export class WorkerService { // The worker daemon is spawned with cwd=marketplace-plugin-dir (not a git // repo), so we can't seed adoption with process.cwd(). Instead, discover // parent repos from recorded pending_messages.cwd values. + let adoptions: Awaited> | null = null; try { - const adoptions = await adoptMergedWorktreesForAllKnownRepos({}); + adoptions = await adoptMergedWorktreesForAllKnownRepos({}); + } catch (err) { + // [ANTI-PATTERN IGNORED]: Worktree adoption is best-effort on startup; failure must not block worker initialization + if (err instanceof Error) { + logger.error('WORKER', 'Worktree adoption failed (non-fatal)', {}, err); + } else { + logger.error('WORKER', 'Worktree adoption failed (non-fatal) with non-Error', {}, new Error(String(err))); + } + } + if (adoptions) { for (const adoption of adoptions) { if (adoption.adoptedObservations > 0 || adoption.adoptedSummaries > 0 || adoption.chromaUpdates > 0) { logger.info('SYSTEM', 'Merged worktrees adopted on startup', adoption); @@ -385,8 +400,6 @@ export class WorkerService { }); } } - } catch (err) { - logger.error('SYSTEM', 'Worktree adoption failed (non-fatal)', {}, err as Error); } // Initialize ChromaMcpManager only if Chroma is enabled @@ -493,8 +506,11 @@ export class WorkerService { }); try { await transport.close(); - } catch { - // Best effort: the supervisor handles later process cleanup for survivors. + } catch (transportCloseError) { + // [ANTI-PATTERN IGNORED]: transport.close() is best-effort cleanup after MCP connection already failed; supervisor handles orphan processes + logger.debug('WORKER', 'transport.close() failed during MCP cleanup', { + error: transportCloseError instanceof Error ? transportCloseError.message : String(transportCloseError) + }); } logger.info('WORKER', 'Bundled MCP server remains available for external stdio clients', { path: mcpServerPath @@ -534,7 +550,12 @@ export class WorkerService { logger.info('SYSTEM', `Reaped ${reaped} stale sessions`); } } catch (e) { - logger.error('SYSTEM', 'Stale session reaper error', { error: e instanceof Error ? e.message : String(e) }); + // [ANTI-PATTERN IGNORED]: setInterval callback cannot throw; reaper retries on next tick (every 2 min) + if (e instanceof Error) { + logger.error('WORKER', 'Stale session reaper error', {}, e); + } else { + logger.error('WORKER', 'Stale session reaper error with non-Error', {}, new Error(String(e))); + } } }, 2 * 60 * 1000); @@ -571,31 +592,40 @@ export class WorkerService { const configPath = settings.CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH || DEFAULT_CONFIG_PATH; const resolvedConfigPath = expandHomePath(configPath); + // Ensure sample config exists (setup, outside try) + if (!existsSync(resolvedConfigPath)) { + writeSampleConfig(configPath); + logger.info('TRANSCRIPT', 'Created default transcript watch config', { + configPath: resolvedConfigPath + }); + } + + const transcriptConfig = loadTranscriptWatchConfig(configPath); + const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH); + try { - if (!existsSync(resolvedConfigPath)) { - writeSampleConfig(configPath); - logger.info('TRANSCRIPT', 'Created default transcript watch config', { - configPath: resolvedConfigPath - }); - } - - const transcriptConfig = loadTranscriptWatchConfig(configPath); - const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH); - this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath); await this.transcriptWatcher.start(); - logger.info('TRANSCRIPT', 'Transcript watcher started', { - configPath: resolvedConfigPath, - statePath, - watches: transcriptConfig.watches.length - }); } catch (error) { this.transcriptWatcher?.stop(); this.transcriptWatcher = null; - logger.error('TRANSCRIPT', 'Failed to start transcript watcher (continuing without Codex ingestion)', { - configPath: resolvedConfigPath - }, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Failed to start transcript watcher (continuing without Codex ingestion)', { + configPath: resolvedConfigPath + }, error); + } else { + logger.error('WORKER', 'Failed to start transcript watcher with non-Error (continuing without Codex ingestion)', { + configPath: resolvedConfigPath + }, new Error(String(error))); + } + // [ANTI-PATTERN IGNORED]: Transcript watcher is intentionally non-fatal so Claude hooks remain usable even if transcript ingestion is misconfigured + return; } + logger.info('TRANSCRIPT', 'Transcript watcher started', { + configPath: resolvedConfigPath, + statePath, + watches: transcriptConfig.watches.length + }); } /** @@ -693,7 +723,8 @@ export class WorkerService { } // Detect stale resume failures - SDK session context was lost - if ((errorMessage.includes('aborted by user') || errorMessage.includes('No conversation found')) + const staleResumePatterns = ['aborted by user', 'No conversation found']; + if (staleResumePatterns.some(p => errorMessage.includes(p)) && session.memorySessionId) { logger.warn('SDK', 'Detected stale resume failure, clearing memorySessionId for fresh start', { sessionId: session.sessionDbId, @@ -798,16 +829,30 @@ export class WorkerService { /** * Match errors that indicate the Claude Code process/session is gone (resume impossible). * Used to trigger graceful fallback instead of leaving pending messages stuck forever. + * + * These patterns come from the Claude SDK's ProcessTransport and related internals. + * The SDK does not export typed error classes, so string matching on normalized + * messages is the only reliable detection method. Each pattern corresponds to a + * specific SDK failure mode: + * - 'process aborted by user': user cancelled the Claude Code session + * - 'processtransport': transport layer disconnected + * - 'not ready for writing': stdio pipe to Claude process is closed + * - 'session generator failed': wrapper error from our own agent layer + * - 'claude code process': process exited or was killed */ + private static readonly SESSION_TERMINATED_PATTERNS = [ + 'process aborted by user', + 'processtransport', + 'not ready for writing', + 'session generator failed', + 'claude code process', + ] as const; + private isSessionTerminatedError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error); const normalized = msg.toLowerCase(); - return ( - normalized.includes('process aborted by user') || - normalized.includes('processtransport') || - normalized.includes('not ready for writing') || - normalized.includes('session generator failed') || - normalized.includes('claude code process') + return WorkerService.SESSION_TERMINATED_PATTERNS.some( + pattern => normalized.includes(pattern) ); } @@ -835,10 +880,15 @@ export class WorkerService { await this.geminiAgent.startSession(session, this); return; } catch (e) { - logger.warn('SDK', 'Fallback Gemini failed, trying OpenRouter', { - sessionId: sessionDbId, - error: e instanceof Error ? e.message : String(e) - }); + // [ANTI-PATTERN IGNORED]: Fallback chain by design — Gemini failure falls through to OpenRouter attempt + if (e instanceof Error) { + logger.warn('WORKER', 'Fallback Gemini failed, trying OpenRouter', { + sessionId: sessionDbId, + }); + logger.error('WORKER', 'Gemini fallback error detail', { sessionId: sessionDbId }, e); + } else { + logger.error('WORKER', 'Gemini fallback failed with non-Error', { sessionId: sessionDbId }, new Error(String(e))); + } } } @@ -847,10 +897,12 @@ export class WorkerService { await this.openRouterAgent.startSession(session, this); return; } catch (e) { - logger.warn('SDK', 'Fallback OpenRouter failed', { - sessionId: sessionDbId, - error: e instanceof Error ? e.message : String(e) - }); + // [ANTI-PATTERN IGNORED]: Last fallback in chain — failure falls through to message abandonment, which is the designed terminal behavior + if (e instanceof Error) { + logger.error('WORKER', 'Fallback OpenRouter failed, will abandon messages', { sessionId: sessionDbId }, e); + } else { + logger.error('WORKER', 'Fallback OpenRouter failed with non-Error, will abandon messages', { sessionId: sessionDbId }, new Error(String(e))); + } } } @@ -909,37 +961,50 @@ export class WorkerService { const STALE_SESSION_THRESHOLD_MS = 6 * 60 * 60 * 1000; const staleThreshold = Date.now() - STALE_SESSION_THRESHOLD_MS; - try { - const staleSessionIds = sessionStore.db.prepare(` - SELECT id FROM sdk_sessions - WHERE status = 'active' AND started_at_epoch < ? - `).all(staleThreshold) as { id: number }[]; + const staleSessionIds = sessionStore.db.prepare(` + SELECT id FROM sdk_sessions + WHERE status = 'active' AND started_at_epoch < ? + `).all(staleThreshold) as { id: number }[]; - if (staleSessionIds.length > 0) { - const ids = staleSessionIds.map(r => r.id); - const placeholders = ids.map(() => '?').join(','); + if (staleSessionIds.length > 0) { + const ids = staleSessionIds.map(r => r.id); + const placeholders = ids.map(() => '?').join(','); + const now = Date.now(); + try { sessionStore.db.prepare(` UPDATE sdk_sessions SET status = 'failed', completed_at_epoch = ? WHERE id IN (${placeholders}) - `).run(Date.now(), ...ids); - + `).run(now, ...ids); logger.info('SYSTEM', `Marked ${ids.length} stale sessions as failed`); + } catch (error) { + // [ANTI-PATTERN IGNORED]: Stale session cleanup is best-effort; pending queue processing below must still proceed + if (error instanceof Error) { + logger.error('WORKER', 'Failed to mark stale sessions as failed', { staleCount: ids.length }, error); + } else { + logger.error('WORKER', 'Failed to mark stale sessions as failed with non-Error', { staleCount: ids.length }, new Error(String(error))); + } + } + try { const msgResult = sessionStore.db.prepare(` UPDATE pending_messages SET status = 'failed', failed_at_epoch = ? WHERE status = 'pending' AND session_db_id IN (${placeholders}) - `).run(Date.now(), ...ids); - + `).run(now, ...ids); if (msgResult.changes > 0) { logger.info('SYSTEM', `Marked ${msgResult.changes} pending messages from stale sessions as failed`); } + } catch (error) { + // [ANTI-PATTERN IGNORED]: Pending message cleanup is best-effort; queue processing below must still proceed + if (error instanceof Error) { + logger.error('WORKER', 'Failed to clean up stale pending messages', { staleCount: ids.length }, error); + } else { + logger.error('WORKER', 'Failed to clean up stale pending messages with non-Error', { staleCount: ids.length }, new Error(String(error))); + } } - } catch (error) { - logger.error('SYSTEM', 'Failed to clean up stale sessions', {}, error as Error); } const orphanedSessionIds = pendingStore.getSessionsWithPendingMessages(); @@ -958,28 +1023,34 @@ export class WorkerService { for (const sessionDbId of orphanedSessionIds) { if (result.sessionsStarted >= sessionLimit) break; + const existingSession = this.sessionManager.getSession(sessionDbId); + if (existingSession?.generatorPromise) { + result.sessionsSkipped++; + continue; + } + try { - const existingSession = this.sessionManager.getSession(sessionDbId); - if (existingSession?.generatorPromise) { - result.sessionsSkipped++; - continue; - } - const session = this.sessionManager.initializeSession(sessionDbId); - logger.info('SYSTEM', `Starting processor for session ${sessionDbId}`, { - project: session.project, - pendingCount: pendingStore.getPendingCount(sessionDbId) - }); - this.startSessionProcessor(session, 'startup-recovery'); result.sessionsStarted++; result.startedSessionIds.push(sessionDbId); - - await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { - logger.error('SYSTEM', `Failed to process session ${sessionDbId}`, {}, error as Error); + if (error instanceof Error) { + logger.error('WORKER', `Failed to initialize/start session ${sessionDbId}`, { sessionDbId }, error); + } else { + logger.error('WORKER', `Failed to initialize/start session ${sessionDbId} with non-Error`, { sessionDbId }, new Error(String(error))); + } result.sessionsSkipped++; + // [ANTI-PATTERN IGNORED]: Per-session failure must not abort the loop; other sessions may still be recoverable + continue; } + + logger.info('SYSTEM', `Starting processor for session ${sessionDbId}`, { + project: this.sessionManager.getSession(sessionDbId)?.project, + pendingCount: pendingStore.getPendingCount(sessionDbId) + }); + + await new Promise(resolve => setTimeout(resolve, 100)); } return result; diff --git a/src/services/worker-spawner.ts b/src/services/worker-spawner.ts index cc833bf0..86a852a6 100644 --- a/src/services/worker-spawner.ts +++ b/src/services/worker-spawner.ts @@ -53,7 +53,12 @@ function shouldSkipSpawnOnWindows(): boolean { try { const modifiedTimeMs = statSync(lockPath).mtimeMs; return Date.now() - modifiedTimeMs < WINDOWS_SPAWN_COOLDOWN_MS; - } catch { + } catch (error) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Could not stat worker spawn lock file', {}, error); + } else { + logger.debug('SYSTEM', 'Could not stat worker spawn lock file', { error: String(error) }); + } return false; } } diff --git a/src/services/worker/BranchManager.ts b/src/services/worker/BranchManager.ts index 4027af45..60e3015d 100644 --- a/src/services/worker/BranchManager.ts +++ b/src/services/worker/BranchManager.ts @@ -118,35 +118,36 @@ export function getBranchInfo(): BranchInfo { }; } + // Get current branch + let branch: string; + let status: string; try { - // Get current branch - const branch = execGit(['rev-parse', '--abbrev-ref', 'HEAD']); - - // Check if dirty (has uncommitted changes) - const status = execGit(['status', '--porcelain']); - const isDirty = status.length > 0; - - // Determine if on beta branch - const isBeta = branch.startsWith('beta'); - - return { - branch, - isBeta, - isGitRepo: true, - isDirty, - canSwitch: true // We can always switch (will discard local changes) - }; + branch = execGit(['rev-parse', '--abbrev-ref', 'HEAD']); + status = execGit(['status', '--porcelain']); } catch (error) { - logger.error('BRANCH', 'Failed to get branch info', {}, error as Error); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('WORKER', 'Failed to get branch info', {}, error instanceof Error ? error : new Error(errorMessage)); return { branch: null, isBeta: false, isGitRepo: true, isDirty: false, canSwitch: false, - error: (error as Error).message + error: errorMessage }; } + + // Determine branch state from git results + const isDirty = status.length > 0; + const isBeta = branch.startsWith('beta'); + + return { + branch, + isBeta, + isGitRepo: true, + isDirty, + canSwitch: true // We can always switch (will discard local changes) + }; } /** @@ -243,7 +244,8 @@ export async function switchBranch(targetBranch: string): Promise } } catch (recoveryError) { // [POSSIBLY RELEVANT]: Recovery checkout failed, user needs manual intervention - already logging main error above - logger.error('BRANCH', 'Recovery checkout also failed', { originalBranch: info.branch }, recoveryError as Error); + const recoveryErrorMessage = recoveryError instanceof Error ? recoveryError.message : String(recoveryError); + logger.error('WORKER', 'Recovery checkout also failed', { originalBranch: info.branch }, recoveryError instanceof Error ? recoveryError : new Error(recoveryErrorMessage)); } return { @@ -266,17 +268,20 @@ export async function pullUpdates(): Promise { }; } + // SECURITY: Validate branch name before use + if (!isValidBranchName(info.branch)) { + return { + success: false, + error: `Invalid current branch name: ${info.branch}` + }; + } + + logger.info('BRANCH', 'Pulling updates', { branch: info.branch }); + + // Prepare install marker path + const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version'); + try { - // SECURITY: Validate branch name before use - if (!isValidBranchName(info.branch)) { - return { - success: false, - error: `Invalid current branch name: ${info.branch}` - }; - } - - logger.info('BRANCH', 'Pulling updates', { branch: info.branch }); - // Discard local changes first execGit(['checkout', '--', '.']); @@ -285,26 +290,26 @@ export async function pullUpdates(): Promise { execGit(['pull', 'origin', info.branch]); // Clear install marker and reinstall - const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version'); if (existsSync(installMarker)) { unlinkSync(installMarker); } execNpm(['install'], NPM_INSTALL_TIMEOUT_MS); - - logger.success('BRANCH', 'Updates pulled', { branch: info.branch }); - - return { - success: true, - branch: info.branch, - message: `Updated ${info.branch}. Worker will restart automatically.` - }; } catch (error) { - logger.error('BRANCH', 'Pull failed', {}, error as Error); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('WORKER', 'Pull failed', {}, error instanceof Error ? error : new Error(errorMessage)); return { success: false, - error: `Pull failed: ${(error as Error).message}` + error: `Pull failed: ${errorMessage}` }; } + + logger.success('BRANCH', 'Updates pulled', { branch: info.branch }); + + return { + success: true, + branch: info.branch, + message: `Updated ${info.branch}. Worker will restart automatically.` + }; } /** diff --git a/src/services/worker/GeminiAgent.ts b/src/services/worker/GeminiAgent.ts index 2aff53a5..85fc7df8 100644 --- a/src/services/worker/GeminiAgent.ts +++ b/src/services/worker/GeminiAgent.ts @@ -22,6 +22,7 @@ import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { estimateTokens } from '../../shared/timeline-formatting.js'; import type { ActiveSession, ConversationMessage } from '../worker-types.js'; import { ModeManager } from '../domain/ModeManager.js'; +import type { ModeConfig } from '../domain/types.js'; import { processAgentResponse, shouldFallbackToClaude, @@ -135,228 +136,246 @@ export class GeminiAgent { * Uses multi-turn conversation to maintain context across messages */ async startSession(session: ActiveSession, worker?: WorkerRef): Promise { + // --- Configuration & validation (no try needed - throws clear errors) --- + const { apiKey, model, rateLimitingEnabled } = this.getGeminiConfig(); + + if (!apiKey) { + throw new Error('Gemini API key not configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_API_KEY environment variable.'); + } + + // Generate synthetic memorySessionId (Gemini is stateless, doesn't return session IDs) + if (!session.memorySessionId) { + const syntheticMemorySessionId = `gemini-${session.contentSessionId}-${Date.now()}`; + session.memorySessionId = syntheticMemorySessionId; + this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId); + logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=Gemini`); + } + + // Load active mode and build initial prompt + const mode = ModeManager.getInstance().getActiveMode(); + const initPrompt = session.lastPromptNumber === 1 + ? buildInitPrompt(session.project, session.contentSessionId, session.userPrompt, mode) + : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.contentSessionId, mode); + + // --- Init query: API call + response processing --- + session.conversationHistory.push({ role: 'user', content: initPrompt }); + let initResponse: { content: string; tokensUsed?: number }; try { - // Get Gemini configuration - const { apiKey, model, rateLimitingEnabled } = this.getGeminiConfig(); - - if (!apiKey) { - throw new Error('Gemini API key not configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_API_KEY environment variable.'); - } - - // Generate synthetic memorySessionId (Gemini is stateless, doesn't return session IDs) - if (!session.memorySessionId) { - const syntheticMemorySessionId = `gemini-${session.contentSessionId}-${Date.now()}`; - session.memorySessionId = syntheticMemorySessionId; - this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId); - logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=Gemini`); - } - - // Load active mode - const mode = ModeManager.getInstance().getActiveMode(); - - // Build initial prompt - const initPrompt = session.lastPromptNumber === 1 - ? buildInitPrompt(session.project, session.contentSessionId, session.userPrompt, mode) - : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.contentSessionId, mode); - - // Add to conversation history and query Gemini with full context - session.conversationHistory.push({ role: 'user', content: initPrompt }); - const initResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); - - if (initResponse.content) { - // Add response to conversation history - session.conversationHistory.push({ role: 'assistant', content: initResponse.content }); - - // Track token usage - const tokensUsed = initResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - - // Process response using shared ResponseProcessor (no original timestamp for init - not from queue) - await processAgentResponse( - initResponse.content, - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - null, - 'Gemini', - undefined, - model - ); + initResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); + } catch (error: unknown) { + if (error instanceof Error) { + logger.error('SDK', 'Gemini init query failed', { sessionId: session.sessionDbId, model }, error); } else { - logger.error('SDK', 'Empty Gemini init response - session may lack context', { - sessionId: session.sessionDbId, - model - }); + logger.error('SDK', 'Gemini init query failed with non-Error', { sessionId: session.sessionDbId, model }, new Error(String(error))); } + return this.handleGeminiError(error, session, worker); + } - // Process pending messages - // Track cwd from messages for CLAUDE.md generation - let lastCwd: string | undefined; + if (initResponse.content) { + session.conversationHistory.push({ role: 'assistant', content: initResponse.content }); + const tokensUsed = initResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + await processAgentResponse(initResponse.content, session, this.dbManager, this.sessionManager, worker, tokensUsed, null, 'Gemini', undefined, model); + } else { + logger.error('SDK', 'Empty Gemini init response - session may lack context', { sessionId: session.sessionDbId, model }); + } - for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { - // CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage - // The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed() - session.processingMessageIds.push(message._persistentId); - - // Capture subagent identity from the claimed message so ResponseProcessor - // can label observation rows with the originating Claude Code subagent. - // Always overwrite (even with null) so a main-session message after a subagent - // message clears the stale identity; otherwise mixed batches could mislabel. - session.pendingAgentId = message.agentId ?? null; - session.pendingAgentType = message.agentType ?? null; - - // Capture cwd from each message for worktree support - if (message.cwd) { - lastCwd = message.cwd; - } - // Capture earliest timestamp BEFORE processing (will be cleared after) - // This ensures backlog messages get their original timestamps, not current time - const originalTimestamp = session.earliestPendingTimestamp; - - if (message.type === 'observation') { - // Update last prompt number - if (message.prompt_number !== undefined) { - session.lastPromptNumber = message.prompt_number; - } - - // CRITICAL: Check memorySessionId BEFORE making expensive LLM call - // This prevents wasting tokens when we won't be able to store the result anyway - if (!session.memorySessionId) { - throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.'); - } - - // Build observation prompt - const obsPrompt = buildObservationPrompt({ - id: 0, - tool_name: message.tool_name!, - tool_input: JSON.stringify(message.tool_input), - tool_output: JSON.stringify(message.tool_response), - created_at_epoch: originalTimestamp ?? Date.now(), - cwd: message.cwd - }); - - // Add to conversation history and query Gemini with full context - session.conversationHistory.push({ role: 'user', content: obsPrompt }); - const obsResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); - - let tokensUsed = 0; - if (obsResponse.content) { - // Add response to conversation history - session.conversationHistory.push({ role: 'assistant', content: obsResponse.content }); - - tokensUsed = obsResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - } - - // Process response using shared ResponseProcessor - if (obsResponse.content) { - await processAgentResponse( - obsResponse.content, - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - originalTimestamp, - 'Gemini', - lastCwd, - model - ); - } else { - logger.warn('SDK', 'Empty Gemini observation response, skipping processing to preserve message', { - sessionId: session.sessionDbId, - messageId: session.processingMessageIds[session.processingMessageIds.length - 1] - }); - // Don't confirm - leave message for stale recovery - } - - } else if (message.type === 'summarize') { - // CRITICAL: Check memorySessionId BEFORE making expensive LLM call - if (!session.memorySessionId) { - throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.'); - } - - // Build summary prompt - const summaryPrompt = buildSummaryPrompt({ - id: session.sessionDbId, - memory_session_id: session.memorySessionId, - project: session.project, - user_prompt: session.userPrompt, - last_assistant_message: message.last_assistant_message || '' - }, mode); - - // Add to conversation history and query Gemini with full context - session.conversationHistory.push({ role: 'user', content: summaryPrompt }); - const summaryResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); - - let tokensUsed = 0; - if (summaryResponse.content) { - // Add response to conversation history - session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content }); - - tokensUsed = summaryResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - } - - // Process response using shared ResponseProcessor - if (summaryResponse.content) { - await processAgentResponse( - summaryResponse.content, - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - originalTimestamp, - 'Gemini', - lastCwd, - model - ); - } else { - logger.warn('SDK', 'Empty Gemini summary response, skipping processing to preserve message', { - sessionId: session.sessionDbId, - messageId: session.processingMessageIds[session.processingMessageIds.length - 1] - }); - // Don't confirm - leave message for stale recovery - } - } + // --- Message processing loop: iterate pending messages --- + try { + await this.processMessageLoop(session, worker, apiKey, model, rateLimitingEnabled, mode); + } catch (error: unknown) { + if (error instanceof Error) { + logger.error('SDK', 'Gemini message loop failed', { sessionId: session.sessionDbId, model }, error); + } else { + logger.error('SDK', 'Gemini message loop failed with non-Error', { sessionId: session.sessionDbId, model }, new Error(String(error))); } + return this.handleGeminiError(error, session, worker); + } - // Mark session complete - const sessionDuration = Date.now() - session.startTime; - logger.success('SDK', 'Gemini agent completed', { + // Mark session complete + const sessionDuration = Date.now() - session.startTime; + logger.success('SDK', 'Gemini agent completed', { + sessionId: session.sessionDbId, + duration: `${(sessionDuration / 1000).toFixed(1)}s`, + historyLength: session.conversationHistory.length + }); + } + + /** + * Process pending messages from the session queue. + * Extracted from startSession to keep try blocks focused. + */ + private async processMessageLoop( + session: ActiveSession, + worker: WorkerRef | undefined, + apiKey: string, + model: GeminiModel, + rateLimitingEnabled: boolean, + mode: ModeConfig + ): Promise { + // Track cwd from messages for CLAUDE.md generation + let lastCwd: string | undefined; + + for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { + // CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage + // The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed() + session.processingMessageIds.push(message._persistentId); + + // Capture subagent identity from the claimed message so ResponseProcessor + // can label observation rows with the originating Claude Code subagent. + // Always overwrite (even with null) so a main-session message after a subagent + // message clears the stale identity; otherwise mixed batches could mislabel. + session.pendingAgentId = message.agentId ?? null; + session.pendingAgentType = message.agentType ?? null; + + // Capture cwd from each message for worktree support + if (message.cwd) { + lastCwd = message.cwd; + } + // Capture earliest timestamp BEFORE processing (will be cleared after) + // This ensures backlog messages get their original timestamps, not current time + const originalTimestamp = session.earliestPendingTimestamp; + + if (message.type === 'observation') { + await this.processObservationMessage(session, message, worker, apiKey, model, rateLimitingEnabled, originalTimestamp, lastCwd); + } else if (message.type === 'summarize') { + await this.processSummaryMessage(session, message, worker, apiKey, model, rateLimitingEnabled, mode, originalTimestamp, lastCwd); + } + } + } + + /** + * Process a single observation message via Gemini API. + */ + private async processObservationMessage( + session: ActiveSession, + message: { type: string; prompt_number?: number; tool_name?: string; tool_input?: unknown; tool_response?: unknown; cwd?: string }, + worker: WorkerRef | undefined, + apiKey: string, + model: GeminiModel, + rateLimitingEnabled: boolean, + originalTimestamp: number | null, + lastCwd: string | undefined + ): Promise { + // Update last prompt number + if (message.prompt_number !== undefined) { + session.lastPromptNumber = message.prompt_number; + } + + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + // This prevents wasting tokens when we won't be able to store the result anyway + if (!session.memorySessionId) { + throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + // Build observation prompt + const obsPrompt = buildObservationPrompt({ + id: 0, + tool_name: message.tool_name!, + tool_input: JSON.stringify(message.tool_input), + tool_output: JSON.stringify(message.tool_response), + created_at_epoch: originalTimestamp ?? Date.now(), + cwd: message.cwd + }); + + session.conversationHistory.push({ role: 'user', content: obsPrompt }); + const obsResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); + + let tokensUsed = 0; + if (obsResponse.content) { + session.conversationHistory.push({ role: 'assistant', content: obsResponse.content }); + tokensUsed = obsResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + if (obsResponse.content) { + await processAgentResponse(obsResponse.content, session, this.dbManager, this.sessionManager, worker, tokensUsed, originalTimestamp, 'Gemini', lastCwd, model); + } else { + logger.warn('SDK', 'Empty Gemini observation response, skipping processing to preserve message', { sessionId: session.sessionDbId, - duration: `${(sessionDuration / 1000).toFixed(1)}s`, + messageId: session.processingMessageIds[session.processingMessageIds.length - 1] + }); + // Don't confirm - leave message for stale recovery + } + } + + /** + * Process a single summary message via Gemini API. + */ + private async processSummaryMessage( + session: ActiveSession, + message: { type: string; last_assistant_message?: string }, + worker: WorkerRef | undefined, + apiKey: string, + model: GeminiModel, + rateLimitingEnabled: boolean, + mode: ModeConfig, + originalTimestamp: number | null, + lastCwd: string | undefined + ): Promise { + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + if (!session.memorySessionId) { + throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + // Build summary prompt + const summaryPrompt = buildSummaryPrompt({ + id: session.sessionDbId, + memory_session_id: session.memorySessionId, + project: session.project, + user_prompt: session.userPrompt, + last_assistant_message: message.last_assistant_message || '' + }, mode); + + session.conversationHistory.push({ role: 'user', content: summaryPrompt }); + const summaryResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled); + + let tokensUsed = 0; + if (summaryResponse.content) { + session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content }); + tokensUsed = summaryResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + if (summaryResponse.content) { + await processAgentResponse(summaryResponse.content, session, this.dbManager, this.sessionManager, worker, tokensUsed, originalTimestamp, 'Gemini', lastCwd, model); + } else { + logger.warn('SDK', 'Empty Gemini summary response, skipping processing to preserve message', { + sessionId: session.sessionDbId, + messageId: session.processingMessageIds[session.processingMessageIds.length - 1] + }); + // Don't confirm - leave message for stale recovery + } + } + + /** + * Handle errors from Gemini API calls with abort detection and Claude fallback. + * Shared by init query and message processing try blocks. + */ + private handleGeminiError(error: unknown, session: ActiveSession, worker?: WorkerRef): Promise | never { + if (isAbortError(error)) { + logger.warn('SDK', 'Gemini agent aborted', { sessionId: session.sessionDbId }); + throw error; + } + + // Check if we should fall back to Claude + if (shouldFallbackToClaude(error) && this.fallbackAgent) { + logger.warn('SDK', 'Gemini API failed, falling back to Claude SDK', { + sessionDbId: session.sessionDbId, + error: error instanceof Error ? error.message : String(error), historyLength: session.conversationHistory.length }); - } catch (error: unknown) { - if (isAbortError(error)) { - logger.warn('SDK', 'Gemini agent aborted', { sessionId: session.sessionDbId }); - throw error; - } - - // Check if we should fall back to Claude - if (shouldFallbackToClaude(error) && this.fallbackAgent) { - logger.warn('SDK', 'Gemini API failed, falling back to Claude SDK', { - sessionDbId: session.sessionDbId, - error: error instanceof Error ? error.message : String(error), - historyLength: session.conversationHistory.length - }); - - // Fall back to Claude - it will use the same session with shared conversationHistory - // Note: With claim-and-delete queue pattern, messages are already deleted on claim - return this.fallbackAgent.startSession(session, worker); - } - - logger.failure('SDK', 'Gemini agent error', { sessionDbId: session.sessionDbId }, error as Error); - throw error; + // Fall back to Claude - it will use the same session with shared conversationHistory + // Note: With claim-and-delete queue pattern, messages are already deleted on claim + return this.fallbackAgent.startSession(session, worker); } + + logger.failure('SDK', 'Gemini agent error', { sessionDbId: session.sessionDbId }, error as Error); + throw error; } /** diff --git a/src/services/worker/OpenRouterAgent.ts b/src/services/worker/OpenRouterAgent.ts index 6e190ce9..8e2917af 100644 --- a/src/services/worker/OpenRouterAgent.ts +++ b/src/services/worker/OpenRouterAgent.ts @@ -17,6 +17,7 @@ import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { logger } from '../../utils/logger.js'; import { ModeManager } from '../domain/ModeManager.js'; +import type { ModeConfig } from '../domain/types.js'; import type { ActiveSession, ConversationMessage } from '../worker-types.js'; import { DatabaseManager } from './DatabaseManager.js'; import { SessionManager } from './SessionManager.js'; @@ -84,212 +85,268 @@ export class OpenRouterAgent { * Uses multi-turn conversation to maintain context across messages */ async startSession(session: ActiveSession, worker?: WorkerRef): Promise { + // Get OpenRouter configuration (pure lookup, no external I/O) + const { apiKey, model, siteUrl, appName } = this.getOpenRouterConfig(); + + if (!apiKey) { + throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.'); + } + + // Generate synthetic memorySessionId (OpenRouter is stateless, doesn't return session IDs) + if (!session.memorySessionId) { + const syntheticMemorySessionId = `openrouter-${session.contentSessionId}-${Date.now()}`; + session.memorySessionId = syntheticMemorySessionId; + this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId); + logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=OpenRouter`); + } + + // Load active mode + const mode = ModeManager.getInstance().getActiveMode(); + + // Build initial prompt + const initPrompt = session.lastPromptNumber === 1 + ? buildInitPrompt(session.project, session.contentSessionId, session.userPrompt, mode) + : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.contentSessionId, mode); + + // Send init prompt to OpenRouter + session.conversationHistory.push({ role: 'user', content: initPrompt }); + try { - // Get OpenRouter configuration - const { apiKey, model, siteUrl, appName } = this.getOpenRouterConfig(); - - if (!apiKey) { - throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.'); - } - - // Generate synthetic memorySessionId (OpenRouter is stateless, doesn't return session IDs) - if (!session.memorySessionId) { - const syntheticMemorySessionId = `openrouter-${session.contentSessionId}-${Date.now()}`; - session.memorySessionId = syntheticMemorySessionId; - this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId); - logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=OpenRouter`); - } - - // Load active mode - const mode = ModeManager.getInstance().getActiveMode(); - - // Build initial prompt - const initPrompt = session.lastPromptNumber === 1 - ? buildInitPrompt(session.project, session.contentSessionId, session.userPrompt, mode) - : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.contentSessionId, mode); - - // Add to conversation history and query OpenRouter with full context - session.conversationHistory.push({ role: 'user', content: initPrompt }); const initResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName); - - if (initResponse.content) { - // Add response to conversation history - // session.conversationHistory.push({ role: 'assistant', content: initResponse.content }); - - // Track token usage - const tokensUsed = initResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - - // Process response using shared ResponseProcessor (no original timestamp for init - not from queue) - await processAgentResponse( - initResponse.content, - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - null, - 'OpenRouter', - undefined, // No lastCwd yet - before message processing - model - ); - } else { - logger.error('SDK', 'Empty OpenRouter init response - session may lack context', { - sessionId: session.sessionDbId, - model - }); - } - - // Track lastCwd from messages for CLAUDE.md generation - let lastCwd: string | undefined; - - // Process pending messages - for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { - // CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage - // The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed() - session.processingMessageIds.push(message._persistentId); - - // Capture subagent identity from the claimed message so ResponseProcessor - // can label observation rows with the originating Claude Code subagent. - // Always overwrite (even with null) so a main-session message after a subagent - // message clears the stale identity; otherwise mixed batches could mislabel. - session.pendingAgentId = message.agentId ?? null; - session.pendingAgentType = message.agentType ?? null; - - // Capture cwd from messages for proper worktree support - if (message.cwd) { - lastCwd = message.cwd; - } - // Capture earliest timestamp BEFORE processing (will be cleared after) - const originalTimestamp = session.earliestPendingTimestamp; - - if (message.type === 'observation') { - // Update last prompt number - if (message.prompt_number !== undefined) { - session.lastPromptNumber = message.prompt_number; - } - - // CRITICAL: Check memorySessionId BEFORE making expensive LLM call - // This prevents wasting tokens when we won't be able to store the result anyway - if (!session.memorySessionId) { - throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.'); - } - - // Build observation prompt - const obsPrompt = buildObservationPrompt({ - id: 0, - tool_name: message.tool_name!, - tool_input: JSON.stringify(message.tool_input), - tool_output: JSON.stringify(message.tool_response), - created_at_epoch: originalTimestamp ?? Date.now(), - cwd: message.cwd - }); - - // Add to conversation history and query OpenRouter with full context - session.conversationHistory.push({ role: 'user', content: obsPrompt }); - const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName); - - let tokensUsed = 0; - if (obsResponse.content) { - // Add response to conversation history - // session.conversationHistory.push({ role: 'assistant', content: obsResponse.content }); - - tokensUsed = obsResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - } - - // Process response using shared ResponseProcessor - await processAgentResponse( - obsResponse.content || '', - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - originalTimestamp, - 'OpenRouter', - lastCwd, - model - ); - - } else if (message.type === 'summarize') { - // CRITICAL: Check memorySessionId BEFORE making expensive LLM call - if (!session.memorySessionId) { - throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.'); - } - - // Build summary prompt - const summaryPrompt = buildSummaryPrompt({ - id: session.sessionDbId, - memory_session_id: session.memorySessionId, - project: session.project, - user_prompt: session.userPrompt, - last_assistant_message: message.last_assistant_message || '' - }, mode); - - // Add to conversation history and query OpenRouter with full context - session.conversationHistory.push({ role: 'user', content: summaryPrompt }); - const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName); - - let tokensUsed = 0; - if (summaryResponse.content) { - // Add response to conversation history - // session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content }); - - tokensUsed = summaryResponse.tokensUsed || 0; - session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); - session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); - } - - // Process response using shared ResponseProcessor - await processAgentResponse( - summaryResponse.content || '', - session, - this.dbManager, - this.sessionManager, - worker, - tokensUsed, - originalTimestamp, - 'OpenRouter', - lastCwd, - model - ); - } - } - - // Mark session complete - const sessionDuration = Date.now() - session.startTime; - logger.success('SDK', 'OpenRouter agent completed', { - sessionId: session.sessionDbId, - duration: `${(sessionDuration / 1000).toFixed(1)}s`, - historyLength: session.conversationHistory.length, - model - }); - + await this.handleInitResponse(initResponse, session, worker, model); } catch (error: unknown) { - if (isAbortError(error)) { - logger.warn('SDK', 'OpenRouter agent aborted', { sessionId: session.sessionDbId }); - throw error; + if (error instanceof Error) { + logger.error('SDK', 'OpenRouter init failed', { sessionId: session.sessionDbId, model }, error); + } else { + logger.error('SDK', 'OpenRouter init failed with non-Error', { sessionId: session.sessionDbId, model }, new Error(String(error))); } + await this.handleSessionError(error, session, worker); + return; + } - // Check if we should fall back to Claude - if (shouldFallbackToClaude(error) && this.fallbackAgent) { - logger.warn('SDK', 'OpenRouter API failed, falling back to Claude SDK', { - sessionDbId: session.sessionDbId, - error: error instanceof Error ? error.message : String(error), - historyLength: session.conversationHistory.length - }); + // Track lastCwd from messages for CLAUDE.md generation + let lastCwd: string | undefined; - // Fall back to Claude - it will use the same session with shared conversationHistory - // Note: With claim-and-delete queue pattern, messages are already deleted on claim - return this.fallbackAgent.startSession(session, worker); + // Process pending messages + try { + for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { + lastCwd = await this.processOneMessage(session, message, lastCwd, apiKey, model, siteUrl, appName, worker, mode); } + } catch (error: unknown) { + if (error instanceof Error) { + logger.error('SDK', 'OpenRouter message processing failed', { sessionId: session.sessionDbId, model }, error); + } else { + logger.error('SDK', 'OpenRouter message processing failed with non-Error', { sessionId: session.sessionDbId, model }, new Error(String(error))); + } + await this.handleSessionError(error, session, worker); + return; + } - logger.failure('SDK', 'OpenRouter agent error', { sessionDbId: session.sessionDbId }, error as Error); + // Mark session complete + const sessionDuration = Date.now() - session.startTime; + logger.success('SDK', 'OpenRouter agent completed', { + sessionId: session.sessionDbId, + duration: `${(sessionDuration / 1000).toFixed(1)}s`, + historyLength: session.conversationHistory.length, + model + }); + } + + /** + * Prepare common message metadata before processing. + * Tracks message IDs and captures subagent identity. + */ + private prepareMessageMetadata(session: ActiveSession, message: { _persistentId: number; agentId?: string | null; agentType?: string | null }): void { + // CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage + session.processingMessageIds.push(message._persistentId); + + // Capture subagent identity from the claimed message so ResponseProcessor + // can label observation rows with the originating Claude Code subagent. + // Always overwrite (even with null) so a main-session message after a subagent + // message clears the stale identity; otherwise mixed batches could mislabel. + session.pendingAgentId = message.agentId ?? null; + session.pendingAgentType = message.agentType ?? null; + } + + /** + * Handle the init response from OpenRouter: update token counts and process or log empty. + */ + private async handleInitResponse( + initResponse: { content: string; tokensUsed?: number }, + session: ActiveSession, + worker: WorkerRef | undefined, + model: string + ): Promise { + if (initResponse.content) { + const tokensUsed = initResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + + await processAgentResponse( + initResponse.content, session, this.dbManager, this.sessionManager, + worker, tokensUsed, null, 'OpenRouter', undefined, model + ); + } else { + logger.error('SDK', 'Empty OpenRouter init response - session may lack context', { + sessionId: session.sessionDbId, model + }); + } + } + + /** + * Process one message from the iterator: prepare metadata, dispatch to observation or summary handler. + * Returns the updated lastCwd value. + */ + private async processOneMessage( + session: ActiveSession, + message: { _persistentId: number; agentId?: string | null; agentType?: string | null; type?: string; cwd?: string; prompt_number?: number; tool_name?: string; tool_input?: unknown; tool_response?: unknown; last_assistant_message?: string }, + lastCwd: string | undefined, + apiKey: string, + model: string, + siteUrl: string | undefined, + appName: string | undefined, + worker: WorkerRef | undefined, + mode: ModeConfig + ): Promise { + this.prepareMessageMetadata(session, message); + + if (message.cwd) { + lastCwd = message.cwd; + } + const originalTimestamp = session.earliestPendingTimestamp; + + if (message.type === 'observation') { + await this.processObservationMessage( + session, message, originalTimestamp, lastCwd, + apiKey, model, siteUrl, appName, worker, mode + ); + } else if (message.type === 'summarize') { + await this.processSummaryMessage( + session, message, originalTimestamp, lastCwd, + apiKey, model, siteUrl, appName, worker, mode + ); + } + + return lastCwd; + } + + /** + * Process a single observation message: build prompt, call OpenRouter, store result. + */ + private async processObservationMessage( + session: ActiveSession, + message: { prompt_number?: number; tool_name?: string; tool_input?: unknown; tool_response?: unknown; cwd?: string }, + originalTimestamp: number | null, + lastCwd: string | undefined, + apiKey: string, + model: string, + siteUrl: string | undefined, + appName: string | undefined, + worker: WorkerRef | undefined, + _mode: ModeConfig + ): Promise { + if (message.prompt_number !== undefined) { + session.lastPromptNumber = message.prompt_number; + } + + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + if (!session.memorySessionId) { + throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + const obsPrompt = buildObservationPrompt({ + id: 0, + tool_name: message.tool_name!, + tool_input: JSON.stringify(message.tool_input), + tool_output: JSON.stringify(message.tool_response), + created_at_epoch: originalTimestamp ?? Date.now(), + cwd: message.cwd + }); + + session.conversationHistory.push({ role: 'user', content: obsPrompt }); + const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName); + + let tokensUsed = 0; + if (obsResponse.content) { + tokensUsed = obsResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + await processAgentResponse( + obsResponse.content || '', session, this.dbManager, this.sessionManager, + worker, tokensUsed, originalTimestamp, 'OpenRouter', lastCwd, model + ); + } + + /** + * Process a single summary message: build prompt, call OpenRouter, store result. + */ + private async processSummaryMessage( + session: ActiveSession, + message: { last_assistant_message?: string }, + originalTimestamp: number | null, + lastCwd: string | undefined, + apiKey: string, + model: string, + siteUrl: string | undefined, + appName: string | undefined, + worker: WorkerRef | undefined, + mode: ModeConfig + ): Promise { + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + if (!session.memorySessionId) { + throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + const summaryPrompt = buildSummaryPrompt({ + id: session.sessionDbId, + memory_session_id: session.memorySessionId, + project: session.project, + user_prompt: session.userPrompt, + last_assistant_message: message.last_assistant_message || '' + }, mode); + + session.conversationHistory.push({ role: 'user', content: summaryPrompt }); + const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName); + + let tokensUsed = 0; + if (summaryResponse.content) { + tokensUsed = summaryResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + await processAgentResponse( + summaryResponse.content || '', session, this.dbManager, this.sessionManager, + worker, tokensUsed, originalTimestamp, 'OpenRouter', lastCwd, model + ); + } + + /** + * Handle errors from session processing: abort re-throw, fallback to Claude, or log and re-throw. + */ + private async handleSessionError(error: unknown, session: ActiveSession, worker?: WorkerRef): Promise { + if (isAbortError(error)) { + logger.warn('SDK', 'OpenRouter agent aborted', { sessionId: session.sessionDbId }); throw error; } + + if (shouldFallbackToClaude(error) && this.fallbackAgent) { + logger.warn('SDK', 'OpenRouter API failed, falling back to Claude SDK', { + sessionDbId: session.sessionDbId, + error: error instanceof Error ? error.message : String(error), + historyLength: session.conversationHistory.length + }); + + // Fall back to Claude - it will use the same session with shared conversationHistory + // Note: With claim-and-delete queue pattern, messages are already deleted on claim + await this.fallbackAgent.startSession(session, worker); + return; + } + + logger.failure('SDK', 'OpenRouter agent error', { sessionDbId: session.sessionDbId }, error as Error); + throw error; } /** diff --git a/src/services/worker/PaginationHelper.ts b/src/services/worker/PaginationHelper.ts index 2e5fd96e..7838b1ae 100644 --- a/src/services/worker/PaginationHelper.ts +++ b/src/services/worker/PaginationHelper.ts @@ -55,7 +55,11 @@ export class PaginationHelper { // Return as JSON string return JSON.stringify(strippedPaths); } catch (err) { - logger.debug('WORKER', 'File paths is plain string, using as-is', {}, err as Error); + if (err instanceof Error) { + logger.debug('WORKER', 'File paths is plain string, using as-is', {}, err); + } else { + logger.debug('WORKER', 'File paths is plain string, using as-is', { rawError: String(err) }); + } return filePathsStr; } } diff --git a/src/services/worker/ProcessRegistry.ts b/src/services/worker/ProcessRegistry.ts index 5a41eaf7..c4d6de25 100644 --- a/src/services/worker/ProcessRegistry.ts +++ b/src/services/worker/ProcessRegistry.ts @@ -395,8 +395,11 @@ export function createPidCapturingSpawn(sessionDbId: number) { try { existing.process.kill('SIGTERM'); exited = existing.process.exitCode !== null; - } catch { + } catch (error: unknown) { // Already dead — safe to unregister immediately + if (error instanceof Error) { + logger.warn('WORKER', `Failed to kill duplicate process PID ${existing.pid}, likely already dead`, { existingPid: existing.pid, sessionDbId }, error); + } exited = true; } @@ -495,7 +498,11 @@ export function startOrphanReaper(getActiveSessionIds: () => Set, interv logger.info('PROCESS', `Reaper cleaned up ${killed} orphaned processes`, { killed }); } } catch (error) { - logger.error('PROCESS', 'Reaper error', {}, error as Error); + if (error instanceof Error) { + logger.error('WORKER', 'Reaper error', {}, error); + } else { + logger.error('WORKER', 'Reaper error', { rawError: String(error) }); + } } }, intervalMs); diff --git a/src/services/worker/SDKAgent.ts b/src/services/worker/SDKAgent.ts index 01ba4afb..0cf98141 100644 --- a/src/services/worker/SDKAgent.ts +++ b/src/services/worker/SDKAgent.ts @@ -480,7 +480,11 @@ export class SDKAgent { if (claudePath) return claudePath; } catch (error) { // [ANTI-PATTERN IGNORED]: Fallback behavior - which/where failed, continue to throw clear error - logger.debug('SDK', 'Claude executable auto-detection failed', {}, error as Error); + if (error instanceof Error) { + logger.debug('SDK', 'Claude executable auto-detection failed', {}, error); + } else { + logger.debug('SDK', 'Claude executable auto-detection failed with non-Error', {}, new Error(String(error))); + } } throw new Error('Claude executable not found. Please either:\n1. Add "claude" to your system PATH, or\n2. Set CLAUDE_CODE_PATH in ~/.claude-mem/settings.json'); diff --git a/src/services/worker/SearchManager.ts b/src/services/worker/SearchManager.ts index ba64fb09..5f30884f 100644 --- a/src/services/worker/SearchManager.ts +++ b/src/services/worker/SearchManager.ts @@ -67,6 +67,23 @@ export class SearchManager { return await this.chromaSync.queryChroma(query, limit, whereFilter); } + private async searchChromaForTimeline(query: string, ninetyDaysAgo: number): Promise { + const chromaResults = await this.queryChroma(query, 100); + logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 }); + + if (chromaResults?.ids && chromaResults.ids.length > 0) { + const recentIds = chromaResults.ids.filter((_id, idx) => { + const meta = chromaResults.metadatas[idx]; + return meta && meta.created_at_epoch > ninetyDaysAgo; + }); + + if (recentIds.length > 0) { + return this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: 1 }); + } + } + return []; + } + /** * Helper to normalize query parameters from URL-friendly format * Converts comma-separated strings to arrays and flattens date params @@ -439,24 +456,13 @@ export class SearchManager { let results: ObservationSearchResult[] = []; if (this.chromaSync) { + logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {}); + const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; try { - logger.debug('SEARCH', 'Using hybrid semantic search for timeline query', {}); - const chromaResults = await this.queryChroma(query, 100); - logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 }); - - if (chromaResults?.ids && chromaResults.ids.length > 0) { - const ninetyDaysAgo = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS; - const recentIds = chromaResults.ids.filter((_id, idx) => { - const meta = chromaResults.metadatas[idx]; - return meta && meta.created_at_epoch > ninetyDaysAgo; - }); - - if (recentIds.length > 0) { - results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: 1 }); - } - } + results = await this.searchChromaForTimeline(query, ninetyDaysAgo); } catch (chromaError) { - logger.error('SEARCH', 'Chroma search failed for timeline, continuing without semantic results', {}, chromaError as Error); + const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError)); + logger.error('WORKER', 'Chroma search failed for timeline, continuing without semantic results', {}, errorObject); } } @@ -689,25 +695,29 @@ export class SearchManager { // Search for decision-type observations if (this.chromaSync) { - try { - if (query) { - // Semantic search filtered to decision type - logger.debug('SEARCH', 'Using Chroma semantic search with type=decision filter', {}); + if (query) { + // Semantic search filtered to decision type + logger.debug('SEARCH', 'Using Chroma semantic search with type=decision filter', {}); + try { const chromaResults = await this.queryChroma(query, Math.min((filters.limit || 20) * 2, 100), { type: 'decision' }); const obsIds = chromaResults.ids; if (obsIds.length > 0) { results = this.sessionStore.getObservationsByIds(obsIds, { ...filters, type: 'decision' }); - // Preserve Chroma ranking order results.sort((a, b) => obsIds.indexOf(a.id) - obsIds.indexOf(b.id)); } - } else { - // No query: get all decisions, rank by "decision" keyword - logger.debug('SEARCH', 'Using metadata-first + semantic ranking for decisions', {}); - const metadataResults = this.sessionSearch.findByType('decision', filters); + } catch (chromaError) { + const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError)); + logger.error('WORKER', 'Chroma search failed for decisions, falling back to metadata search', {}, errorObject); + } + } else { + // No query: get all decisions, rank by "decision" keyword + logger.debug('SEARCH', 'Using metadata-first + semantic ranking for decisions', {}); + const metadataResults = this.sessionSearch.findByType('decision', filters); - if (metadataResults.length > 0) { - const ids = metadataResults.map(obs => obs.id); + if (metadataResults.length > 0) { + const ids = metadataResults.map(obs => obs.id); + try { const chromaResults = await this.queryChroma('decision', Math.min(ids.length, 100)); const rankedIds: number[] = []; @@ -721,10 +731,11 @@ export class SearchManager { results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 }); results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); } + } catch (chromaError) { + const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError)); + logger.error('WORKER', 'Chroma semantic ranking failed for decisions, falling back to metadata search', {}, errorObject); } } - } catch (chromaError) { - logger.error('SEARCH', 'Chroma search failed for decisions, falling back to metadata search', {}, chromaError as Error); } } @@ -763,20 +774,20 @@ export class SearchManager { // Search for change-type observations and change-related concepts if (this.chromaSync) { - try { - logger.debug('SEARCH', 'Using hybrid search for change-related observations', {}); + logger.debug('SEARCH', 'Using hybrid search for change-related observations', {}); - // Get all observations with type="change" or concepts containing change - const typeResults = this.sessionSearch.findByType('change', filters); - const conceptChangeResults = this.sessionSearch.findByConcept('change', filters); - const conceptWhatChangedResults = this.sessionSearch.findByConcept('what-changed', filters); + // Get all observations with type="change" or concepts containing change + const typeResults = this.sessionSearch.findByType('change', filters); + const conceptChangeResults = this.sessionSearch.findByConcept('change', filters); + const conceptWhatChangedResults = this.sessionSearch.findByConcept('what-changed', filters); - // Combine and deduplicate - const allIds = new Set(); - [...typeResults, ...conceptChangeResults, ...conceptWhatChangedResults].forEach(obs => allIds.add(obs.id)); + // Combine and deduplicate + const allIds = new Set(); + [...typeResults, ...conceptChangeResults, ...conceptWhatChangedResults].forEach(obs => allIds.add(obs.id)); - if (allIds.size > 0) { - const idsArray = Array.from(allIds); + if (allIds.size > 0) { + const idsArray = Array.from(allIds); + try { const chromaResults = await this.queryChroma('what changed', Math.min(idsArray.length, 100)); const rankedIds: number[] = []; @@ -790,9 +801,10 @@ export class SearchManager { results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 }); results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); } + } catch (chromaError) { + const errorObject = chromaError instanceof Error ? chromaError : new Error(String(chromaError)); + logger.error('WORKER', 'Chroma search failed for changes, falling back to metadata search', {}, errorObject); } - } catch (chromaError) { - logger.error('SEARCH', 'Chroma search failed for changes, falling back to metadata search', {}, chromaError as Error); } } @@ -1373,7 +1385,8 @@ export class SearchManager { lines.push(`**Files Read:** ${filesRead.join(', ')}`); } } catch (error) { - logger.debug('WORKER', 'files_read is plain string, using as-is', {}, error as Error); + const errorObject = error instanceof Error ? error : new Error(String(error)); + logger.debug('WORKER', 'files_read is plain string, using as-is', {}, errorObject); if (summary.files_read.trim()) { lines.push(`**Files Read:** ${summary.files_read}`); } @@ -1388,7 +1401,8 @@ export class SearchManager { lines.push(`**Files Edited:** ${filesEdited.join(', ')}`); } } catch (error) { - logger.debug('WORKER', 'files_edited is plain string, using as-is', {}, error as Error); + const errorObject = error instanceof Error ? error : new Error(String(error)); + logger.debug('WORKER', 'files_edited is plain string, using as-is', {}, errorObject); if (summary.files_edited.trim()) { lines.push(`**Files Edited:** ${summary.files_edited}`); } diff --git a/src/services/worker/SessionManager.ts b/src/services/worker/SessionManager.ts index 1c2e106d..357cfc13 100644 --- a/src/services/worker/SessionManager.ts +++ b/src/services/worker/SessionManager.ts @@ -69,7 +69,13 @@ export function detectStaleGenerator( if (proc && proc.exitCode === null) { try { proc.kill('SIGKILL'); - } catch {} + } catch (error) { + if (error instanceof Error) { + logger.warn('SESSION', 'Failed to SIGKILL stale generator subprocess', {}, error); + } else { + logger.warn('SESSION', 'Failed to SIGKILL stale generator subprocess with non-Error', {}, new Error(String(error))); + } + } } // Signal the SDK agent loop to exit session.abortController.abort(); @@ -292,10 +298,17 @@ export class SessionManager { sessionId: sessionDbId }); } catch (error) { - logger.error('SESSION', 'Failed to persist observation to DB', { - sessionId: sessionDbId, - tool: data.tool_name - }, error); + if (error instanceof Error) { + logger.error('SESSION', 'Failed to persist observation to DB', { + sessionId: sessionDbId, + tool: data.tool_name + }, error); + } else { + logger.error('SESSION', 'Failed to persist observation to DB with non-Error', { + sessionId: sessionDbId, + tool: data.tool_name + }, new Error(String(error))); + } throw error; // Don't continue if we can't persist } @@ -343,9 +356,15 @@ export class SessionManager { sessionId: sessionDbId }); } catch (error) { - logger.error('SESSION', 'Failed to persist summarize to DB', { - sessionId: sessionDbId - }, error); + if (error instanceof Error) { + logger.error('SESSION', 'Failed to persist summarize to DB', { + sessionId: sessionDbId + }, error); + } else { + logger.error('SESSION', 'Failed to persist summarize to DB with non-Error', { + sessionId: sessionDbId + }, new Error(String(error))); + } throw error; // Don't continue if we can't persist } @@ -397,9 +416,15 @@ export class SessionManager { try { await getSupervisor().getRegistry().reapSession(sessionDbId); } catch (error) { - logger.warn('SESSION', 'Supervisor reapSession failed (non-blocking)', { - sessionId: sessionDbId - }, error as Error); + if (error instanceof Error) { + logger.warn('SESSION', 'Supervisor reapSession failed (non-blocking)', { + sessionId: sessionDbId + }, error); + } else { + logger.warn('SESSION', 'Supervisor reapSession failed (non-blocking) with non-Error', { + sessionId: sessionDbId + }, new Error(String(error))); + } } // 4. Cleanup @@ -469,7 +494,11 @@ export class SessionManager { try { trackedProcess.process.kill('SIGKILL'); } catch (err) { - logger.warn('SESSION', 'Failed to SIGKILL subprocess for stale generator', { sessionDbId }, err as Error); + if (err instanceof Error) { + logger.warn('SESSION', 'Failed to SIGKILL subprocess for stale generator', { sessionDbId }, err); + } else { + logger.warn('SESSION', 'Failed to SIGKILL subprocess for stale generator with non-Error', { sessionDbId }, new Error(String(err))); + } } } // Signal the SDK agent loop to exit after the subprocess dies diff --git a/src/services/worker/SettingsManager.ts b/src/services/worker/SettingsManager.ts index 9a5a41d0..a1127d9a 100644 --- a/src/services/worker/SettingsManager.ts +++ b/src/services/worker/SettingsManager.ts @@ -43,7 +43,11 @@ export class SettingsManager { return settings; } catch (error) { - logger.debug('WORKER', 'Failed to load settings, using defaults', {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', 'Failed to load settings, using defaults', {}, error); + } else { + logger.debug('WORKER', 'Failed to load settings, using defaults', { rawError: String(error) }); + } return { ...this.defaultSettings }; } } diff --git a/src/services/worker/http/BaseRouteHandler.ts b/src/services/worker/http/BaseRouteHandler.ts index 52eca2af..bb7e89a8 100644 --- a/src/services/worker/http/BaseRouteHandler.ts +++ b/src/services/worker/http/BaseRouteHandler.ts @@ -27,8 +27,9 @@ export abstract class BaseRouteHandler { result.catch(error => this.handleError(res, error as Error)); } } catch (error) { - logger.error('HTTP', 'Route handler error', { path: req.path }, error as Error); - this.handleError(res, error as Error); + const normalizedError = error instanceof Error ? error : new Error(String(error)); + logger.error('HTTP', 'Route handler error', { path: req.path }, normalizedError); + this.handleError(res, normalizedError); } }; } diff --git a/src/services/worker/http/routes/CorpusRoutes.ts b/src/services/worker/http/routes/CorpusRoutes.ts index 5ddfa188..1c891468 100644 --- a/src/services/worker/http/routes/CorpusRoutes.ts +++ b/src/services/worker/http/routes/CorpusRoutes.ts @@ -7,6 +7,7 @@ import express, { Request, Response } from 'express'; import { BaseRouteHandler } from '../BaseRouteHandler.js'; +import { logger } from '../../../../utils/logger.js'; import { CorpusStore } from '../../knowledge/CorpusStore.js'; import { CorpusBuilder } from '../../knowledge/CorpusBuilder.js'; import { KnowledgeAgent } from '../../knowledge/KnowledgeAgent.js'; @@ -93,7 +94,10 @@ export class CorpusRoutes extends BaseRouteHandler { if (typeof value === 'string') { try { parsed = JSON.parse(value); - } catch { + } catch (parseError: unknown) { + if (parseError instanceof Error) { + logger.debug('HTTP', `${fieldName} is not valid JSON, treating as comma-separated string`, { value }); + } parsed = value.split(',').map(part => part.trim()).filter(Boolean); } } diff --git a/src/services/worker/http/routes/SearchRoutes.ts b/src/services/worker/http/routes/SearchRoutes.ts index 74255c02..8b5096bf 100644 --- a/src/services/worker/http/routes/SearchRoutes.ts +++ b/src/services/worker/http/routes/SearchRoutes.ts @@ -269,35 +269,34 @@ export class SearchRoutes extends BaseRouteHandler { return; } + let result: any; try { - const result = await this.searchManager.search({ - query, - type: 'observations', - project, - limit: String(limit), - format: 'json' + result = await this.searchManager.search({ + query, type: 'observations', project, limit: String(limit), format: 'json' }); - - const observations = (result as any)?.observations || []; - if (!observations.length) { - res.json({ context: '', count: 0 }); - return; - } - - // Format as compact markdown for context injection - const lines: string[] = ['## Relevant Past Work (semantic match)\n']; - for (const obs of observations.slice(0, limit)) { - const date = obs.created_at?.slice(0, 10) || ''; - lines.push(`### ${obs.title || 'Observation'} (${date})`); - if (obs.narrative) lines.push(obs.narrative); - lines.push(''); - } - - res.json({ context: lines.join('\n'), count: observations.length }); } catch (error) { - logger.error('SEARCH', 'Semantic context query failed', {}, error as Error); + const normalizedError = error instanceof Error ? error : new Error(String(error)); + logger.error('HTTP', 'Semantic context query failed', { query, project }, normalizedError); res.json({ context: '', count: 0 }); + return; } + + const observations = result?.observations || []; + if (!observations.length) { + res.json({ context: '', count: 0 }); + return; + } + + // Format as compact markdown for context injection + const lines: string[] = ['## Relevant Past Work (semantic match)\n']; + for (const obs of observations.slice(0, limit)) { + const date = obs.created_at?.slice(0, 10) || ''; + lines.push(`### ${obs.title || 'Observation'} (${date})`); + if (obs.narrative) lines.push(obs.narrative); + lines.push(''); + } + + res.json({ context: lines.join('\n'), count: observations.length }); }); /** diff --git a/src/services/worker/http/routes/SessionRoutes.ts b/src/services/worker/http/routes/SessionRoutes.ts index 091d923b..99c8de4c 100644 --- a/src/services/worker/http/routes/SessionRoutes.ts +++ b/src/services/worker/http/routes/SessionRoutes.ts @@ -222,7 +222,10 @@ export class SessionRoutes extends BaseRouteHandler { session.generatorPromise = agent.startSession(session, this.workerService) .catch(error => { // Only log non-abort errors - if (myController.signal.aborted) return; + if (myController.signal.aborted) { + logger.debug('HTTP', 'Generator catch: ignoring error after abort', { sessionId: session.sessionDbId }); + return; + } const errorMsg = error instanceof Error ? error.message : String(error); @@ -257,9 +260,10 @@ export class SessionRoutes extends BaseRouteHandler { }); } } catch (dbError) { - logger.error('SESSION', 'Failed to mark messages as failed', { + const normalizedDbError = dbError instanceof Error ? dbError : new Error(String(dbError)); + logger.error('HTTP', 'Failed to mark messages as failed', { sessionId: session.sessionDbId - }, dbError as Error); + }, normalizedDbError); } }) .finally(async () => { @@ -285,75 +289,75 @@ export class SessionRoutes extends BaseRouteHandler { // Crash recovery: If not aborted and still has work, restart (with limit) if (!wasAborted) { + const pendingStore = this.sessionManager.getPendingMessageStore(); + const MAX_CONSECUTIVE_RESTARTS = 3; + + let pendingCount: number; try { - const pendingStore = this.sessionManager.getPendingMessageStore(); - const pendingCount = pendingStore.getPendingCount(sessionDbId); + pendingCount = pendingStore.getPendingCount(sessionDbId); + } catch (e) { + const normalizedRecoveryError = e instanceof Error ? e : new Error(String(e)); + logger.error('HTTP', 'Error during recovery check, aborting to prevent leaks', { sessionId: sessionDbId }, normalizedRecoveryError); + session.abortController.abort(); + return; + } - // CRITICAL: Limit consecutive restarts to prevent infinite loops - // This prevents runaway API costs when there's a persistent error (e.g., memorySessionId not captured) - const MAX_CONSECUTIVE_RESTARTS = 3; + if (pendingCount > 0) { + // GUARD: Prevent duplicate crash recovery spawns + if (this.crashRecoveryScheduled.has(sessionDbId)) { + logger.debug('SESSION', 'Crash recovery already scheduled', { sessionDbId }); + return; + } - if (pendingCount > 0) { - // GUARD: Prevent duplicate crash recovery spawns - if (this.crashRecoveryScheduled.has(sessionDbId)) { - logger.debug('SESSION', 'Crash recovery already scheduled', { sessionDbId }); - return; - } + session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1; - session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1; - - if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) { - logger.error('SESSION', `CRITICAL: Generator restart limit exceeded - stopping to prevent runaway costs`, { - sessionId: sessionDbId, - pendingCount, - consecutiveRestarts: session.consecutiveRestarts, - maxRestarts: MAX_CONSECUTIVE_RESTARTS, - action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.' - }); - // Don't restart - abort to prevent further API calls - session.abortController.abort(); - return; - } - - logger.info('SESSION', `Restarting generator after crash/exit with pending work`, { + if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) { + logger.error('SESSION', `CRITICAL: Generator restart limit exceeded - stopping to prevent runaway costs`, { sessionId: sessionDbId, pendingCount, consecutiveRestarts: session.consecutiveRestarts, - maxRestarts: MAX_CONSECUTIVE_RESTARTS + maxRestarts: MAX_CONSECUTIVE_RESTARTS, + action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.' }); - - // Abort OLD controller before replacing to prevent child process leaks - const oldController = session.abortController; - session.abortController = new AbortController(); - oldController.abort(); - - this.crashRecoveryScheduled.add(sessionDbId); - - // Exponential backoff: 1s, 2s, 4s for subsequent restarts - const backoffMs = Math.min(1000 * Math.pow(2, session.consecutiveRestarts - 1), 8000); - - // Delay before restart with exponential backoff - setTimeout(() => { - this.crashRecoveryScheduled.delete(sessionDbId); - const stillExists = this.sessionManager.getSession(sessionDbId); - if (stillExists && !stillExists.generatorPromise) { - this.applyTierRouting(stillExists); - this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery'); - } - }, backoffMs); - } else { - // No pending work - abort to kill the child process + // Don't restart - abort to prevent further API calls session.abortController.abort(); - // Reset restart counter on successful completion - session.consecutiveRestarts = 0; - logger.debug('SESSION', 'Aborted controller after natural completion', { - sessionId: sessionDbId - }); + return; } - } catch (e) { - // Ignore errors during recovery check, but still abort to prevent leaks - logger.debug('SESSION', 'Error during recovery check, aborting to prevent leaks', { sessionId: sessionDbId, error: e instanceof Error ? e.message : String(e) }); + + logger.info('SESSION', `Restarting generator after crash/exit with pending work`, { + sessionId: sessionDbId, + pendingCount, + consecutiveRestarts: session.consecutiveRestarts, + maxRestarts: MAX_CONSECUTIVE_RESTARTS + }); + + // Abort OLD controller before replacing to prevent child process leaks + const oldController = session.abortController; + session.abortController = new AbortController(); + oldController.abort(); + + this.crashRecoveryScheduled.add(sessionDbId); + + // Exponential backoff: 1s, 2s, 4s for subsequent restarts + const backoffMs = Math.min(1000 * Math.pow(2, session.consecutiveRestarts - 1), 8000); + + // Delay before restart with exponential backoff + setTimeout(() => { + this.crashRecoveryScheduled.delete(sessionDbId); + const stillExists = this.sessionManager.getSession(sessionDbId); + if (stillExists && !stillExists.generatorPromise) { + this.applyTierRouting(stillExists); + this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery'); + } + }, backoffMs); + } else { + // No pending work - abort to kill the child process session.abortController.abort(); + // Reset restart counter on successful completion + session.consecutiveRestarts = 0; + logger.debug('SESSION', 'Aborted controller after natural completion', { + sessionId: sessionDbId + }); } } // NOTE: We do NOT delete the session here anymore. @@ -586,65 +590,67 @@ export class SessionRoutes extends BaseRouteHandler { } } + const store = this.dbManager.getSessionStore(); + + let sessionDbId: number; + let promptNumber: number; try { - const store = this.dbManager.getSessionStore(); - - // Get or create session - const sessionDbId = store.createSDKSession(contentSessionId, project, '', undefined, platformSource); - const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId); - - // Privacy check: skip if user prompt was entirely private - const userPrompt = PrivacyCheckValidator.checkUserPromptPrivacy( - store, - contentSessionId, - promptNumber, - 'observation', - sessionDbId, - { tool_name } - ); - if (!userPrompt) { - res.json({ status: 'skipped', reason: 'private' }); - return; - } - - // Strip memory tags from tool_input and tool_response - const cleanedToolInput = tool_input !== undefined - ? stripMemoryTagsFromJson(JSON.stringify(tool_input)) - : '{}'; - - const cleanedToolResponse = tool_response !== undefined - ? stripMemoryTagsFromJson(JSON.stringify(tool_response)) - : '{}'; - - // Queue observation - this.sessionManager.queueObservation(sessionDbId, { - tool_name, - tool_input: cleanedToolInput, - tool_response: cleanedToolResponse, - prompt_number: promptNumber, - cwd: cwd || (() => { - logger.error('SESSION', 'Missing cwd when queueing observation in SessionRoutes', { - sessionId: sessionDbId, - tool_name - }); - return ''; - })(), - agentId: typeof agentId === 'string' ? agentId : undefined, - agentType: typeof agentType === 'string' ? agentType : undefined, - }); - - // Ensure SDK agent is running - this.ensureGeneratorRunning(sessionDbId, 'observation'); - - // Broadcast observation queued event - this.eventBroadcaster.broadcastObservationQueued(sessionDbId); - - res.json({ status: 'queued' }); + sessionDbId = store.createSDKSession(contentSessionId, project, '', undefined, platformSource); + promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId); } catch (error) { - // Return 200 on recoverable errors so the hook doesn't break - logger.error('SESSION', 'Observation storage failed', { contentSessionId, tool_name }, error as Error); - res.json({ stored: false, reason: (error as Error).message }); + const normalizedError = error instanceof Error ? error : new Error(String(error)); + logger.error('HTTP', 'Observation storage failed', { contentSessionId, tool_name }, normalizedError); + res.json({ stored: false, reason: normalizedError.message }); + return; } + + // Privacy check: skip if user prompt was entirely private + const userPrompt = PrivacyCheckValidator.checkUserPromptPrivacy( + store, + contentSessionId, + promptNumber, + 'observation', + sessionDbId, + { tool_name } + ); + if (!userPrompt) { + res.json({ status: 'skipped', reason: 'private' }); + return; + } + + // Strip memory tags from tool_input and tool_response + const cleanedToolInput = tool_input !== undefined + ? stripMemoryTagsFromJson(JSON.stringify(tool_input)) + : '{}'; + + const cleanedToolResponse = tool_response !== undefined + ? stripMemoryTagsFromJson(JSON.stringify(tool_response)) + : '{}'; + + // Queue observation + this.sessionManager.queueObservation(sessionDbId, { + tool_name, + tool_input: cleanedToolInput, + tool_response: cleanedToolResponse, + prompt_number: promptNumber, + cwd: cwd || (() => { + logger.error('SESSION', 'Missing cwd when queueing observation in SessionRoutes', { + sessionId: sessionDbId, + tool_name + }); + return ''; + })(), + agentId: typeof agentId === 'string' ? agentId : undefined, + agentType: typeof agentType === 'string' ? agentType : undefined, + }); + + // Ensure SDK agent is running + this.ensureGeneratorRunning(sessionDbId, 'observation'); + + // Broadcast observation queued event + this.eventBroadcaster.broadcastObservationQueued(sessionDbId); + + res.json({ status: 'queued' }); }); /** diff --git a/src/services/worker/http/routes/SettingsRoutes.ts b/src/services/worker/http/routes/SettingsRoutes.ts index 66a5f2d6..b05e2bb2 100644 --- a/src/services/worker/http/routes/SettingsRoutes.ts +++ b/src/services/worker/http/routes/SettingsRoutes.ts @@ -74,7 +74,8 @@ export class SettingsRoutes extends BaseRouteHandler { try { settings = JSON.parse(settingsData); } catch (parseError) { - logger.error('SETTINGS', 'Failed to parse settings file', { settingsPath }, parseError as Error); + const normalizedParseError = parseError instanceof Error ? parseError : new Error(String(parseError)); + logger.error('HTTP', 'Failed to parse settings file', { settingsPath }, normalizedParseError); res.status(500).json({ success: false, error: 'Settings file is corrupted. Delete ~/.claude-mem/settings.json to reset.' diff --git a/src/services/worker/http/routes/ViewerRoutes.ts b/src/services/worker/http/routes/ViewerRoutes.ts index 09c08cef..e370f6e5 100644 --- a/src/services/worker/http/routes/ViewerRoutes.ts +++ b/src/services/worker/http/routes/ViewerRoutes.ts @@ -71,7 +71,10 @@ export class ViewerRoutes extends BaseRouteHandler { // Guard: if DB is not yet initialized, return 503 before registering client try { this.dbManager.getSessionStore(); - } catch { + } catch (initError: unknown) { + if (initError instanceof Error) { + logger.warn('HTTP', 'SSE stream requested before DB initialization', {}, initError); + } res.status(503).json({ error: 'Service initializing' }); return; } diff --git a/src/services/worker/knowledge/CorpusBuilder.ts b/src/services/worker/knowledge/CorpusBuilder.ts index d0bbfebf..9cce6d5b 100644 --- a/src/services/worker/knowledge/CorpusBuilder.ts +++ b/src/services/worker/knowledge/CorpusBuilder.ts @@ -23,7 +23,12 @@ function safeParseJsonArray(value: unknown): string[] { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; - } catch { + } catch (error) { + if (error instanceof Error) { + logger.warn('WORKER', 'Failed to parse JSON array field', {}, error); + } else { + logger.warn('WORKER', 'Failed to parse JSON array field (non-Error thrown)', { thrownValue: String(error) }); + } return []; } } diff --git a/src/services/worker/knowledge/CorpusStore.ts b/src/services/worker/knowledge/CorpusStore.ts index b03b131e..6adca9d9 100644 --- a/src/services/worker/knowledge/CorpusStore.ts +++ b/src/services/worker/knowledge/CorpusStore.ts @@ -46,7 +46,11 @@ export class CorpusStore { const raw = fs.readFileSync(filePath, 'utf-8'); return JSON.parse(raw) as CorpusFile; } catch (error) { - logger.error('WORKER', `Failed to read corpus file: ${filePath}`, { error }); + if (error instanceof Error) { + logger.error('WORKER', `Failed to read corpus file: ${filePath}`, {}, error); + } else { + logger.error('WORKER', `Failed to read corpus file: ${filePath} (non-Error thrown)`, { thrownValue: String(error) }); + } return null; } } @@ -73,7 +77,11 @@ export class CorpusStore { session_id: corpus.session_id, }); } catch (error) { - logger.error('WORKER', `Failed to parse corpus file: ${file}`, { error }); + if (error instanceof Error) { + logger.error('WORKER', `Failed to parse corpus file: ${file}`, {}, error); + } else { + logger.error('WORKER', `Failed to parse corpus file: ${file} (non-Error thrown)`, { thrownValue: String(error) }); + } } } diff --git a/src/services/worker/knowledge/KnowledgeAgent.ts b/src/services/worker/knowledge/KnowledgeAgent.ts index 0a915bbb..b8192a20 100644 --- a/src/services/worker/knowledge/KnowledgeAgent.ts +++ b/src/services/worker/knowledge/KnowledgeAgent.ts @@ -96,7 +96,11 @@ export class KnowledgeAgent { // exits with a non-zero code. If we already captured a session_id, // treat this as success — the session was created and primed. if (sessionId) { - logger.debug('WORKER', `SDK process exited after priming corpus "${corpus.name}" — session captured, continuing`, {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', `SDK process exited after priming corpus "${corpus.name}" — session captured, continuing`, {}, error); + } else { + logger.debug('WORKER', `SDK process exited after priming corpus "${corpus.name}" — session captured, continuing (non-Error thrown)`, { thrownValue: String(error) }); + } } else { throw error; } @@ -132,6 +136,11 @@ export class KnowledgeAgent { return result; } catch (error) { if (!this.isSessionResumeError(error)) { + if (error instanceof Error) { + logger.error('WORKER', `Query failed for corpus "${corpus.name}"`, {}, error); + } else { + logger.error('WORKER', `Query failed for corpus "${corpus.name}" (non-Error thrown)`, { thrownValue: String(error) }); + } throw error; } // Session expired or invalid — auto-reprime and retry @@ -207,7 +216,11 @@ export class KnowledgeAgent { // Same as prime() — SDK may throw after all messages are yielded. // If we captured an answer, treat as success. if (answer) { - logger.debug('WORKER', `SDK process exited after query — answer captured, continuing`, {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', `SDK process exited after query — answer captured, continuing`, {}, error); + } else { + logger.debug('WORKER', `SDK process exited after query — answer captured, continuing (non-Error thrown)`, { thrownValue: String(error) }); + } } else { throw error; } @@ -259,7 +272,11 @@ export class KnowledgeAgent { if (claudePath) return claudePath; } catch (error) { - logger.debug('WORKER', 'Claude executable auto-detection failed', {}, error as Error); + if (error instanceof Error) { + logger.debug('WORKER', 'Claude executable auto-detection failed', {}, error); + } else { + logger.debug('WORKER', 'Claude executable auto-detection failed (non-Error thrown)', { thrownValue: String(error) }); + } } throw new Error('Claude executable not found. Please either:\n1. Add "claude" to your system PATH, or\n2. Set CLAUDE_CODE_PATH in ~/.claude-mem/settings.json'); diff --git a/src/services/worker/search/strategies/ChromaSearchStrategy.ts b/src/services/worker/search/strategies/ChromaSearchStrategy.ts index fc7d4188..e8e6a562 100644 --- a/src/services/worker/search/strategies/ChromaSearchStrategy.ts +++ b/src/services/worker/search/strategies/ChromaSearchStrategy.ts @@ -63,82 +63,19 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt let sessions: SessionSummarySearchResult[] = []; let prompts: UserPromptSearchResult[] = []; + // Build Chroma where filter for doc_type and project + const whereFilter = this.buildWhereFilter(searchType, project); + + logger.debug('SEARCH', 'ChromaSearchStrategy: Querying Chroma', { query, searchType }); + try { - // Build Chroma where filter for doc_type and project - const whereFilter = this.buildWhereFilter(searchType, project); - - // Step 1: Chroma semantic search - logger.debug('SEARCH', 'ChromaSearchStrategy: Querying Chroma', { query, searchType }); - const chromaResults = await this.chromaSync.queryChroma( - query, - SEARCH_CONSTANTS.CHROMA_BATCH_SIZE, - whereFilter - ); - - logger.debug('SEARCH', 'ChromaSearchStrategy: Chroma returned matches', { - matchCount: chromaResults.ids.length + return await this.executeChromaSearch(query, whereFilter, { + searchObservations, searchSessions, searchPrompts, + obsType, concepts, files, orderBy, limit, project }); - - if (chromaResults.ids.length === 0) { - // No matches - this is the correct answer - return { - results: { observations: [], sessions: [], prompts: [] }, - usedChroma: true, - fellBack: false, - strategy: 'chroma' - }; - } - - // Step 2: Filter by recency (90 days) - const recentItems = this.filterByRecency(chromaResults); - logger.debug('SEARCH', 'ChromaSearchStrategy: Filtered by recency', { - count: recentItems.length - }); - - // Step 3: Categorize by document type - const categorized = this.categorizeByDocType(recentItems, { - searchObservations, - searchSessions, - searchPrompts - }); - - // Step 4: Hydrate from SQLite with additional filters - if (categorized.obsIds.length > 0) { - const obsOptions = { type: obsType, concepts, files, orderBy, limit, project }; - observations = this.sessionStore.getObservationsByIds(categorized.obsIds, obsOptions); - } - - if (categorized.sessionIds.length > 0) { - sessions = this.sessionStore.getSessionSummariesByIds(categorized.sessionIds, { - orderBy, - limit, - project - }); - } - - if (categorized.promptIds.length > 0) { - prompts = this.sessionStore.getUserPromptsByIds(categorized.promptIds, { - orderBy, - limit, - project - }); - } - - logger.debug('SEARCH', 'ChromaSearchStrategy: Hydrated results', { - observations: observations.length, - sessions: sessions.length, - prompts: prompts.length - }); - - return { - results: { observations, sessions, prompts }, - usedChroma: true, - fellBack: false, - strategy: 'chroma' - }; - } catch (error) { - logger.error('SEARCH', 'ChromaSearchStrategy: Search failed', {}, error as Error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + logger.error('WORKER', 'ChromaSearchStrategy: Search failed', {}, errorObj); // Return empty result - caller may try fallback strategy return { results: { observations: [], sessions: [], prompts: [] }, @@ -149,6 +86,68 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt } } + private async executeChromaSearch( + query: string, + whereFilter: Record | undefined, + options: { + searchObservations: boolean; + searchSessions: boolean; + searchPrompts: boolean; + obsType?: string | string[]; + concepts?: string | string[]; + files?: string | string[]; + orderBy: 'relevance' | 'date_desc' | 'date_asc'; + limit: number; + project?: string; + } + ): Promise { + const chromaResults = await this.chromaSync.queryChroma( + query, + SEARCH_CONSTANTS.CHROMA_BATCH_SIZE, + whereFilter + ); + + if (chromaResults.ids.length === 0) { + return { + results: { observations: [], sessions: [], prompts: [] }, + usedChroma: true, + fellBack: false, + strategy: 'chroma' + }; + } + + const recentItems = this.filterByRecency(chromaResults); + const categorized = this.categorizeByDocType(recentItems, options); + + let observations: ObservationSearchResult[] = []; + let sessions: SessionSummarySearchResult[] = []; + let prompts: UserPromptSearchResult[] = []; + + if (categorized.obsIds.length > 0) { + const obsOptions = { type: options.obsType, concepts: options.concepts, files: options.files, orderBy: options.orderBy, limit: options.limit, project: options.project }; + observations = this.sessionStore.getObservationsByIds(categorized.obsIds, obsOptions); + } + + if (categorized.sessionIds.length > 0) { + sessions = this.sessionStore.getSessionSummariesByIds(categorized.sessionIds, { + orderBy: options.orderBy, limit: options.limit, project: options.project + }); + } + + if (categorized.promptIds.length > 0) { + prompts = this.sessionStore.getUserPromptsByIds(categorized.promptIds, { + orderBy: options.orderBy, limit: options.limit, project: options.project + }); + } + + return { + results: { observations, sessions, prompts }, + usedChroma: true, + fellBack: false, + strategy: 'chroma' + }; + } + /** * Build Chroma where filter for document type and project * diff --git a/src/services/worker/search/strategies/HybridSearchStrategy.ts b/src/services/worker/search/strategies/HybridSearchStrategy.ts index 909350f0..3a8104c7 100644 --- a/src/services/worker/search/strategies/HybridSearchStrategy.ts +++ b/src/services/worker/search/strategies/HybridSearchStrategy.ts @@ -68,50 +68,22 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt const { limit = SEARCH_CONSTANTS.DEFAULT_LIMIT, project, dateRange, orderBy } = options; const filterOptions = { limit, project, dateRange, orderBy }; - try { - logger.debug('SEARCH', 'HybridSearchStrategy: findByConcept', { concept }); + logger.debug('SEARCH', 'HybridSearchStrategy: findByConcept', { concept }); - // Step 1: SQLite metadata filter - const metadataResults = this.sessionSearch.findByConcept(concept, filterOptions); - logger.debug('SEARCH', 'HybridSearchStrategy: Found metadata matches', { - count: metadataResults.length - }); - - if (metadataResults.length === 0) { - return this.emptyResult('hybrid'); - } - - // Step 2: Chroma semantic ranking - const ids = metadataResults.map(obs => obs.id); - const chromaResults = await this.chromaSync.queryChroma( - concept, - Math.min(ids.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE) - ); - - // Step 3: Intersect - keep only IDs from metadata, in Chroma rank order - const rankedIds = this.intersectWithRanking(ids, chromaResults.ids); - logger.debug('SEARCH', 'HybridSearchStrategy: Ranked by semantic relevance', { - count: rankedIds.length - }); - - // Step 4: Hydrate in semantic rank order - if (rankedIds.length > 0) { - const observations = this.sessionStore.getObservationsByIds(rankedIds, { limit }); - // Restore semantic ranking order - observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); - - return { - results: { observations, sessions: [], prompts: [] }, - usedChroma: true, - fellBack: false, - strategy: 'hybrid' - }; - } + // Step 1: SQLite metadata filter + const metadataResults = this.sessionSearch.findByConcept(concept, filterOptions); + if (metadataResults.length === 0) { return this.emptyResult('hybrid'); + } + const ids = metadataResults.map(obs => obs.id); + + try { + return await this.rankAndHydrate(concept, ids, limit); } catch (error) { - logger.error('SEARCH', 'HybridSearchStrategy: findByConcept failed', {}, error as Error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + logger.error('WORKER', 'HybridSearchStrategy: findByConcept failed', {}, errorObj); // Fall back to metadata-only results const results = this.sessionSearch.findByConcept(concept, filterOptions); return { @@ -134,49 +106,22 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt const filterOptions = { limit, project, dateRange, orderBy }; const typeStr = Array.isArray(type) ? type.join(', ') : type; - try { - logger.debug('SEARCH', 'HybridSearchStrategy: findByType', { type: typeStr }); + logger.debug('SEARCH', 'HybridSearchStrategy: findByType', { type: typeStr }); - // Step 1: SQLite metadata filter - const metadataResults = this.sessionSearch.findByType(type as any, filterOptions); - logger.debug('SEARCH', 'HybridSearchStrategy: Found metadata matches', { - count: metadataResults.length - }); - - if (metadataResults.length === 0) { - return this.emptyResult('hybrid'); - } - - // Step 2: Chroma semantic ranking - const ids = metadataResults.map(obs => obs.id); - const chromaResults = await this.chromaSync.queryChroma( - typeStr, - Math.min(ids.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE) - ); - - // Step 3: Intersect with ranking - const rankedIds = this.intersectWithRanking(ids, chromaResults.ids); - logger.debug('SEARCH', 'HybridSearchStrategy: Ranked by semantic relevance', { - count: rankedIds.length - }); - - // Step 4: Hydrate in rank order - if (rankedIds.length > 0) { - const observations = this.sessionStore.getObservationsByIds(rankedIds, { limit }); - observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); - - return { - results: { observations, sessions: [], prompts: [] }, - usedChroma: true, - fellBack: false, - strategy: 'hybrid' - }; - } + // Step 1: SQLite metadata filter + const metadataResults = this.sessionSearch.findByType(type as any, filterOptions); + if (metadataResults.length === 0) { return this.emptyResult('hybrid'); + } + const ids = metadataResults.map(obs => obs.id); + + try { + return await this.rankAndHydrate(typeStr, ids, limit); } catch (error) { - logger.error('SEARCH', 'HybridSearchStrategy: findByType failed', {}, error as Error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + logger.error('WORKER', 'HybridSearchStrategy: findByType failed', {}, errorObj); const results = this.sessionSearch.findByType(type as any, filterOptions); return { results: { observations: results, sessions: [], prompts: [] }, @@ -201,48 +146,23 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt const { limit = SEARCH_CONSTANTS.DEFAULT_LIMIT, project, dateRange, orderBy } = options; const filterOptions = { limit, project, dateRange, orderBy }; - try { - logger.debug('SEARCH', 'HybridSearchStrategy: findByFile', { filePath }); + logger.debug('SEARCH', 'HybridSearchStrategy: findByFile', { filePath }); - // Step 1: SQLite metadata filter - const metadataResults = this.sessionSearch.findByFile(filePath, filterOptions); - logger.debug('SEARCH', 'HybridSearchStrategy: Found file matches', { - observations: metadataResults.observations.length, - sessions: metadataResults.sessions.length - }); - - // Sessions don't need semantic ranking (already summarized) - const sessions = metadataResults.sessions; - - if (metadataResults.observations.length === 0) { - return { observations: [], sessions, usedChroma: false }; - } - - // Step 2: Chroma semantic ranking for observations - const ids = metadataResults.observations.map(obs => obs.id); - const chromaResults = await this.chromaSync.queryChroma( - filePath, - Math.min(ids.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE) - ); - - // Step 3: Intersect with ranking - const rankedIds = this.intersectWithRanking(ids, chromaResults.ids); - logger.debug('SEARCH', 'HybridSearchStrategy: Ranked observations', { - count: rankedIds.length - }); - - // Step 4: Hydrate in rank order - if (rankedIds.length > 0) { - const observations = this.sessionStore.getObservationsByIds(rankedIds, { limit }); - observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); - - return { observations, sessions, usedChroma: true }; - } + // Step 1: SQLite metadata filter + const metadataResults = this.sessionSearch.findByFile(filePath, filterOptions); + const sessions = metadataResults.sessions; + if (metadataResults.observations.length === 0) { return { observations: [], sessions, usedChroma: false }; + } + const ids = metadataResults.observations.map(obs => obs.id); + + try { + return await this.rankAndHydrateForFile(filePath, ids, limit, sessions); } catch (error) { - logger.error('SEARCH', 'HybridSearchStrategy: findByFile failed', {}, error as Error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + logger.error('WORKER', 'HybridSearchStrategy: findByFile failed', {}, errorObj); const results = this.sessionSearch.findByFile(filePath, filterOptions); return { observations: results.observations, @@ -252,6 +172,56 @@ export class HybridSearchStrategy extends BaseSearchStrategy implements SearchSt } } + private async rankAndHydrate( + queryText: string, + metadataIds: number[], + limit: number + ): Promise { + const chromaResults = await this.chromaSync.queryChroma( + queryText, + Math.min(metadataIds.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE) + ); + + const rankedIds = this.intersectWithRanking(metadataIds, chromaResults.ids); + + if (rankedIds.length > 0) { + const observations = this.sessionStore.getObservationsByIds(rankedIds, { limit }); + observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); + + return { + results: { observations, sessions: [], prompts: [] }, + usedChroma: true, + fellBack: false, + strategy: 'hybrid' + }; + } + + return this.emptyResult('hybrid'); + } + + private async rankAndHydrateForFile( + filePath: string, + metadataIds: number[], + limit: number, + sessions: SessionSummarySearchResult[] + ): Promise<{ observations: ObservationSearchResult[]; sessions: SessionSummarySearchResult[]; usedChroma: boolean }> { + const chromaResults = await this.chromaSync.queryChroma( + filePath, + Math.min(metadataIds.length, SEARCH_CONSTANTS.CHROMA_BATCH_SIZE) + ); + + const rankedIds = this.intersectWithRanking(metadataIds, chromaResults.ids); + + if (rankedIds.length > 0) { + const observations = this.sessionStore.getObservationsByIds(rankedIds, { limit }); + observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id)); + + return { observations, sessions, usedChroma: true }; + } + + return { observations: [], sessions, usedChroma: false }; + } + /** * Intersect metadata IDs with Chroma IDs, preserving Chroma's rank order */ diff --git a/src/services/worker/search/strategies/SQLiteSearchStrategy.ts b/src/services/worker/search/strategies/SQLiteSearchStrategy.ts index ba9ffc3b..66492306 100644 --- a/src/services/worker/search/strategies/SQLiteSearchStrategy.ts +++ b/src/services/worker/search/strategies/SQLiteSearchStrategy.ts @@ -64,44 +64,45 @@ export class SQLiteSearchStrategy extends BaseSearchStrategy implements SearchSt hasProject: !!project }); + const obsOptions = searchObservations ? { ...baseOptions, type: obsType, concepts, files } : null; + try { - if (searchObservations) { - const obsOptions = { - ...baseOptions, - type: obsType, - concepts, - files - }; - observations = this.sessionSearch.searchObservations(undefined, obsOptions); - } - - if (searchSessions) { - sessions = this.sessionSearch.searchSessions(undefined, baseOptions); - } - - if (searchPrompts) { - prompts = this.sessionSearch.searchUserPrompts(undefined, baseOptions); - } - - logger.debug('SEARCH', 'SQLiteSearchStrategy: Results', { - observations: observations.length, - sessions: sessions.length, - prompts: prompts.length - }); - - return { - results: { observations, sessions, prompts }, - usedChroma: false, - fellBack: false, - strategy: 'sqlite' - }; - + return this.executeSqliteSearch(obsOptions, searchSessions, searchPrompts, baseOptions); } catch (error) { - logger.error('SEARCH', 'SQLiteSearchStrategy: Search failed', {}, error as Error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + logger.error('WORKER', 'SQLiteSearchStrategy: Search failed', {}, errorObj); return this.emptyResult('sqlite'); } } + private executeSqliteSearch( + obsOptions: Record | null, + searchSessions: boolean, + searchPrompts: boolean, + baseOptions: Record + ): StrategySearchResult { + let observations: ObservationSearchResult[] = []; + let sessions: SessionSummarySearchResult[] = []; + let prompts: UserPromptSearchResult[] = []; + + if (obsOptions) { + observations = this.sessionSearch.searchObservations(undefined, obsOptions); + } + if (searchSessions) { + sessions = this.sessionSearch.searchSessions(undefined, baseOptions); + } + if (searchPrompts) { + prompts = this.sessionSearch.searchUserPrompts(undefined, baseOptions); + } + + return { + results: { observations, sessions, prompts }, + usedChroma: false, + fellBack: false, + strategy: 'sqlite' + }; + } + /** * Find observations by concept (used by findByConcept tool) */ diff --git a/src/shared/EnvManager.ts b/src/shared/EnvManager.ts index 6bfad6b8..9a8c4639 100644 --- a/src/shared/EnvManager.ts +++ b/src/shared/EnvManager.ts @@ -121,8 +121,8 @@ export function loadClaudeMemEnv(): ClaudeMemEnv { if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY; return result; - } catch (error) { - logger.warn('ENV', 'Failed to load .env file', { path: ENV_FILE_PATH }, error as Error); + } catch (error: unknown) { + logger.warn('ENV', 'Failed to load .env file', { path: ENV_FILE_PATH }, error instanceof Error ? error : new Error(String(error))); return {}; } } @@ -131,60 +131,60 @@ export function loadClaudeMemEnv(): ClaudeMemEnv { * Save credentials to ~/.claude-mem/.env */ export function saveClaudeMemEnv(env: ClaudeMemEnv): void { + // Ensure directory exists with restricted permissions (owner only) + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); + } + // Fix permissions on pre-existing directories (mode: is only applied on creation) + // Note: On Windows, chmod has no effect — permissions are controlled via ACLs. + chmodSync(DATA_DIR, 0o700); + + // Load existing to preserve any extra keys + const existing = existsSync(ENV_FILE_PATH) + ? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8')) + : {}; + + // Update with new values + const updated: Record = { ...existing }; + + // Only update managed keys + if (env.ANTHROPIC_API_KEY !== undefined) { + if (env.ANTHROPIC_API_KEY) { + updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; + } else { + delete updated.ANTHROPIC_API_KEY; + } + } + if (env.ANTHROPIC_BASE_URL !== undefined) { + if (env.ANTHROPIC_BASE_URL) { + updated.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL; + } else { + delete updated.ANTHROPIC_BASE_URL; + } + } + if (env.GEMINI_API_KEY !== undefined) { + if (env.GEMINI_API_KEY) { + updated.GEMINI_API_KEY = env.GEMINI_API_KEY; + } else { + delete updated.GEMINI_API_KEY; + } + } + if (env.OPENROUTER_API_KEY !== undefined) { + if (env.OPENROUTER_API_KEY) { + updated.OPENROUTER_API_KEY = env.OPENROUTER_API_KEY; + } else { + delete updated.OPENROUTER_API_KEY; + } + } + try { - // Ensure directory exists with restricted permissions (owner only) - if (!existsSync(DATA_DIR)) { - mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); - } - // Fix permissions on pre-existing directories (mode: is only applied on creation) - // Note: On Windows, chmod has no effect — permissions are controlled via ACLs. - chmodSync(DATA_DIR, 0o700); - - // Load existing to preserve any extra keys - const existing = existsSync(ENV_FILE_PATH) - ? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8')) - : {}; - - // Update with new values - const updated: Record = { ...existing }; - - // Only update managed keys - if (env.ANTHROPIC_API_KEY !== undefined) { - if (env.ANTHROPIC_API_KEY) { - updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; - } else { - delete updated.ANTHROPIC_API_KEY; - } - } - if (env.ANTHROPIC_BASE_URL !== undefined) { - if (env.ANTHROPIC_BASE_URL) { - updated.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL; - } else { - delete updated.ANTHROPIC_BASE_URL; - } - } - if (env.GEMINI_API_KEY !== undefined) { - if (env.GEMINI_API_KEY) { - updated.GEMINI_API_KEY = env.GEMINI_API_KEY; - } else { - delete updated.GEMINI_API_KEY; - } - } - if (env.OPENROUTER_API_KEY !== undefined) { - if (env.OPENROUTER_API_KEY) { - updated.OPENROUTER_API_KEY = env.OPENROUTER_API_KEY; - } else { - delete updated.OPENROUTER_API_KEY; - } - } - writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), { encoding: 'utf-8', mode: 0o600 }); // Explicitly set permissions in case the file already existed before this fix. // writeFileSync's mode option only applies on file creation (O_CREAT), not on overwrites. // Note: On Windows, chmod has no effect — permissions are controlled via ACLs. chmodSync(ENV_FILE_PATH, 0o600); - } catch (error) { - logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error as Error); + } catch (error: unknown) { + logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error instanceof Error ? error : new Error(String(error))); throw error; } } diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index 99cf4013..0bf87519 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -221,8 +221,8 @@ export class SettingsDefaultsManager { writeFileSync(settingsPath, JSON.stringify(defaults, null, 2), 'utf-8'); // Use console instead of logger to avoid circular dependency console.log('[SETTINGS] Created settings file with defaults:', settingsPath); - } catch (error) { - console.warn('[SETTINGS] Failed to create settings file, using in-memory defaults:', settingsPath, error); + } catch (error: unknown) { + console.warn('[SETTINGS] Failed to create settings file, using in-memory defaults:', settingsPath, error instanceof Error ? error.message : String(error)); } // Still apply env var overrides even when file doesn't exist return this.applyEnvOverrides(defaults); @@ -241,8 +241,8 @@ export class SettingsDefaultsManager { try { writeFileSync(settingsPath, JSON.stringify(flatSettings, null, 2), 'utf-8'); console.log('[SETTINGS] Migrated settings file from nested to flat schema:', settingsPath); - } catch (error) { - console.warn('[SETTINGS] Failed to auto-migrate settings file:', settingsPath, error); + } catch (error: unknown) { + console.warn('[SETTINGS] Failed to auto-migrate settings file:', settingsPath, error instanceof Error ? error.message : String(error)); // Continue with in-memory migration even if write fails } } @@ -257,8 +257,8 @@ export class SettingsDefaultsManager { // Apply environment variable overrides (highest priority) return this.applyEnvOverrides(result); - } catch (error) { - console.warn('[SETTINGS] Failed to load settings, using defaults:', settingsPath, error); + } catch (error: unknown) { + console.warn('[SETTINGS] Failed to load settings, using defaults:', settingsPath, error instanceof Error ? error.message : String(error)); // Still apply env var overrides even on error return this.applyEnvOverrides(this.getAllDefaults()); } diff --git a/src/shared/paths.ts b/src/shared/paths.ts index 7a425e4f..bba3e0af 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -146,10 +146,10 @@ export function getCurrentProjectName(): string { windowsHide: true }).trim(); return basename(dirname(gitRoot)) + '/' + basename(gitRoot); - } catch (error) { + } catch (error: unknown) { logger.debug('SYSTEM', 'Git root detection failed, using cwd basename', { cwd: process.cwd() - }, error as Error); + }, error instanceof Error ? error : new Error(String(error))); const cwd = process.cwd(); return basename(dirname(cwd)) + '/' + basename(cwd); } diff --git a/src/shared/plugin-state.ts b/src/shared/plugin-state.ts index 52955005..66b6b761 100644 --- a/src/shared/plugin-state.ts +++ b/src/shared/plugin-state.ts @@ -22,8 +22,9 @@ export function isPluginDisabledInClaudeSettings(): boolean { const raw = readFileSync(settingsPath, 'utf-8'); const settings = JSON.parse(raw); return settings?.enabledPlugins?.[PLUGIN_SETTINGS_KEY] === false; - } catch { + } catch (error: unknown) { // If settings can't be read/parsed, assume not disabled + console.error('[plugin-state] Failed to read Claude settings:', error instanceof Error ? error.message : String(error)); return false; } } diff --git a/src/shared/timeline-formatting.ts b/src/shared/timeline-formatting.ts index 781dba0e..8b1fe4ad 100644 --- a/src/shared/timeline-formatting.ts +++ b/src/shared/timeline-formatting.ts @@ -16,10 +16,10 @@ export function parseJsonArray(json: string | null): string[] { try { const parsed = JSON.parse(json); return Array.isArray(parsed) ? parsed : []; - } catch (err) { + } catch (err: unknown) { logger.debug('PARSER', 'Failed to parse JSON array, using empty fallback', { preview: json?.substring(0, 50) - }, err as Error); + }, err instanceof Error ? err : new Error(String(err))); return []; } } diff --git a/src/shared/worker-utils.ts b/src/shared/worker-utils.ts index f88cf16c..acb1e1b1 100644 --- a/src/shared/worker-utils.ts +++ b/src/shared/worker-utils.ts @@ -148,7 +148,7 @@ function getPluginVersion(): string { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); return packageJson.version; } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; + const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; if (code === 'ENOENT' || code === 'EBUSY') { logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code }); return 'unknown'; @@ -176,30 +176,39 @@ async function getWorkerVersion(): Promise { * Skips comparison when either version is 'unknown' (fix #1042 — avoids restart loops). */ async function checkWorkerVersion(): Promise { + let pluginVersion: string; try { - const pluginVersion = getPluginVersion(); - - // Skip version check if plugin version couldn't be read (shutdown race) - if (pluginVersion === 'unknown') return; - - const workerVersion = await getWorkerVersion(); - - // Skip version check if worker version is 'unknown' (avoids restart loops) - if (workerVersion === 'unknown') return; - - if (pluginVersion !== workerVersion) { - // Just log debug info - auto-restart handles the mismatch in worker-service.ts - logger.debug('SYSTEM', 'Version check', { - pluginVersion, - workerVersion, - note: 'Mismatch will be auto-restarted by worker-service start command' - }); - } - } catch (error) { - // Version check is informational — don't fail the hook - logger.debug('SYSTEM', 'Version check failed', { + pluginVersion = getPluginVersion(); + } catch (error: unknown) { + logger.debug('SYSTEM', 'Version check failed reading plugin version', { error: error instanceof Error ? error.message : String(error) }); + return; + } + + // Skip version check if plugin version couldn't be read (shutdown race) + if (pluginVersion === 'unknown') return; + + let workerVersion: string; + try { + workerVersion = await getWorkerVersion(); + } catch (error: unknown) { + logger.debug('SYSTEM', 'Version check failed reading worker version', { + error: error instanceof Error ? error.message : String(error) + }); + return; + } + + // Skip version check if worker version is 'unknown' (avoids restart loops) + if (workerVersion === 'unknown') return; + + if (pluginVersion !== workerVersion) { + // Just log debug info - auto-restart handles the mismatch in worker-service.ts + logger.debug('SYSTEM', 'Version check', { + pluginVersion, + workerVersion, + note: 'Mismatch will be auto-restarted by worker-service start command' + }); } } diff --git a/src/supervisor/index.ts b/src/supervisor/index.ts index 9e253a87..fd62b0dc 100644 --- a/src/supervisor/index.ts +++ b/src/supervisor/index.ts @@ -69,12 +69,20 @@ class Supervisor { } else { await this.stop(); } - } catch (error) { - logger.error('SYSTEM', 'Error during shutdown', {}, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.error('SYSTEM', 'Error during shutdown', {}, error); + } else { + logger.error('SYSTEM', 'Error during shutdown (non-Error)', { error: String(error) }); + } try { await this.stop(); - } catch (stopError) { - logger.debug('SYSTEM', 'Supervisor shutdown fallback failed', {}, stopError as Error); + } catch (stopError: unknown) { + if (stopError instanceof Error) { + logger.debug('SYSTEM', 'Supervisor shutdown fallback failed', {}, stopError); + } else { + logger.debug('SYSTEM', 'Supervisor shutdown fallback failed', { error: String(stopError) }); + } } } @@ -161,8 +169,15 @@ export function validateWorkerPidFile(options: ValidateWorkerPidOptions = {}): V try { pidInfo = JSON.parse(readFileSync(pidFilePath, 'utf-8')) as PidInfo; - } catch (error) { - logger.warn('SYSTEM', 'Failed to parse worker PID file, removing it', { path: pidFilePath }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.warn('SYSTEM', 'Failed to parse worker PID file, removing it', { path: pidFilePath }, error); + } else { + logger.warn('SYSTEM', 'Failed to parse worker PID file, removing it', { + path: pidFilePath, + error: String(error) + }); + } rmSync(pidFilePath, { force: true }); return 'invalid'; } diff --git a/src/supervisor/process-registry.ts b/src/supervisor/process-registry.ts index ab5717b4..120b8261 100644 --- a/src/supervisor/process-registry.ts +++ b/src/supervisor/process-registry.ts @@ -33,8 +33,14 @@ export function isPidAlive(pid: number): boolean { process.kill(pid, 0); return true; } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - return code === 'EPERM'; + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM') return true; + logger.debug('SYSTEM', 'PID check failed', { pid, code }); + return false; + } + logger.warn('SYSTEM', 'PID check threw non-Error', { pid, error: String(error) }); + return false; } } @@ -65,10 +71,17 @@ export class ProcessRegistry { for (const [id, info] of Object.entries(processes)) { this.entries.set(id, info); } - } catch (error) { - logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', { - path: this.registryPath - }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', { + path: this.registryPath + }, error); + } else { + logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', { + path: this.registryPath, + error: String(error) + }); + } this.entries.clear(); } @@ -168,11 +181,18 @@ export class ProcessRegistry { try { process.kill(record.pid, 'SIGTERM'); } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ESRCH') { - logger.debug('SYSTEM', `Failed to SIGTERM session process PID ${record.pid}`, { - pid: record.pid - }, error as Error); + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ESRCH') { + logger.debug('SYSTEM', `Failed to SIGTERM session process PID ${record.pid}`, { + pid: record.pid + }, error); + } + } else { + logger.warn('SYSTEM', `Failed to SIGTERM session process PID ${record.pid} (non-Error)`, { + pid: record.pid, + error: String(error) + }); } } } @@ -195,11 +215,18 @@ export class ProcessRegistry { try { process.kill(record.pid, 'SIGKILL'); } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ESRCH') { - logger.debug('SYSTEM', `Failed to SIGKILL session process PID ${record.pid}`, { - pid: record.pid - }, error as Error); + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ESRCH') { + logger.debug('SYSTEM', `Failed to SIGKILL session process PID ${record.pid}`, { + pid: record.pid + }, error); + } + } else { + logger.warn('SYSTEM', `Failed to SIGKILL session process PID ${record.pid} (non-Error)`, { + pid: record.pid, + error: String(error) + }); } } } diff --git a/src/supervisor/shutdown.ts b/src/supervisor/shutdown.ts index 07c3078f..ec8c0a1e 100644 --- a/src/supervisor/shutdown.ts +++ b/src/supervisor/shutdown.ts @@ -35,11 +35,19 @@ export async function runShutdownCascade(options: ShutdownCascadeOptions): Promi try { await signalProcess(record.pid, 'SIGTERM'); - } catch (error) { - logger.debug('SYSTEM', 'Failed to send SIGTERM to child process', { - pid: record.pid, - type: record.type - }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Failed to send SIGTERM to child process', { + pid: record.pid, + type: record.type + }, error); + } else { + logger.warn('SYSTEM', 'Failed to send SIGTERM to child process (non-Error)', { + pid: record.pid, + type: record.type, + error: String(error) + }); + } } } @@ -49,11 +57,19 @@ export async function runShutdownCascade(options: ShutdownCascadeOptions): Promi for (const record of survivors) { try { await signalProcess(record.pid, 'SIGKILL'); - } catch (error) { - logger.debug('SYSTEM', 'Failed to force kill child process', { - pid: record.pid, - type: record.type - }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Failed to force kill child process', { + pid: record.pid, + type: record.type + }, error); + } else { + logger.warn('SYSTEM', 'Failed to force kill child process (non-Error)', { + pid: record.pid, + type: record.type, + error: String(error) + }); + } } } @@ -68,8 +84,15 @@ export async function runShutdownCascade(options: ShutdownCascadeOptions): Promi try { rmSync(pidFilePath, { force: true }); - } catch (error) { - logger.debug('SYSTEM', 'Failed to remove PID file during shutdown', { pidFilePath }, error as Error); + } catch (error: unknown) { + if (error instanceof Error) { + logger.debug('SYSTEM', 'Failed to remove PID file during shutdown', { pidFilePath }, error); + } else { + logger.warn('SYSTEM', 'Failed to remove PID file during shutdown (non-Error)', { + pidFilePath, + error: String(error) + }); + } } options.registry.pruneDeadEntries(); @@ -91,10 +114,12 @@ async function signalProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promis if (signal === 'SIGTERM') { try { process.kill(pid, signal); - } catch (error) { - const errno = (error as NodeJS.ErrnoException).code; - if (errno === 'ESRCH') { - return; + } catch (error: unknown) { + if (error instanceof Error) { + const errno = (error as NodeJS.ErrnoException).code; + if (errno === 'ESRCH') { + return; + } } throw error; } @@ -136,10 +161,12 @@ async function signalProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promis try { process.kill(pid, signal); - } catch (error) { - const errno = (error as NodeJS.ErrnoException).code; - if (errno === 'ESRCH') { - return; + } catch (error: unknown) { + if (error instanceof Error) { + const errno = (error as NodeJS.ErrnoException).code; + if (errno === 'ESRCH') { + return; + } } throw error; } @@ -151,7 +178,8 @@ async function loadTreeKill(): Promise { try { const treeKillModule = await import(moduleName); return (treeKillModule.default ?? treeKillModule) as TreeKillFn; - } catch { + } catch (error: unknown) { + logger.debug('SYSTEM', 'tree-kill module not available, using fallback', {}, error instanceof Error ? error : undefined); return null; } } diff --git a/src/ui/viewer/hooks/useContextPreview.ts b/src/ui/viewer/hooks/useContextPreview.ts index f8e8aee4..b81d6595 100644 --- a/src/ui/viewer/hooks/useContextPreview.ts +++ b/src/ui/viewer/hooks/useContextPreview.ts @@ -37,32 +37,35 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult { // Fetch projects on mount useEffect(() => { async function fetchProjects() { + let data: ProjectCatalog; try { const response = await fetch('/api/projects'); - const data = await response.json() as ProjectCatalog; - const nextCatalog: ProjectCatalog = { - projects: data.projects || [], - sources: withDefaultSources(data.sources || []), - projectsBySource: data.projectsBySource || {} - }; - - setCatalog(nextCatalog); - - const preferredSource = getPreferredSource(nextCatalog.sources); - setSelectedSource(preferredSource); - - if (preferredSource) { - const sourceProjects = nextCatalog.projectsBySource[preferredSource] || []; - setProjects(sourceProjects); - setSelectedProject(sourceProjects[0] || null); - return; - } - - setProjects(nextCatalog.projects); - setSelectedProject(nextCatalog.projects[0] || null); - } catch (err) { - console.error('Failed to fetch projects:', err); + data = await response.json() as ProjectCatalog; + } catch (err: unknown) { + console.error('Failed to fetch projects:', err instanceof Error ? err.message : String(err)); + return; } + + const nextCatalog: ProjectCatalog = { + projects: data.projects || [], + sources: withDefaultSources(data.sources || []), + projectsBySource: data.projectsBySource || {} + }; + + setCatalog(nextCatalog); + + const preferredSource = getPreferredSource(nextCatalog.sources); + setSelectedSource(preferredSource); + + if (preferredSource) { + const sourceProjects = nextCatalog.projectsBySource[preferredSource] || []; + setProjects(sourceProjects); + setSelectedProject(sourceProjects[0] || null); + return; + } + + setProjects(nextCatalog.projects); + setSelectedProject(nextCatalog.projects[0] || null); } fetchProjects(); }, []); @@ -105,7 +108,8 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult { } else { setError('Failed to load preview'); } - } catch { + } catch (error: unknown) { + console.error('Failed to load context preview:', error instanceof Error ? error.message : String(error)); setError('Failed to load preview'); } diff --git a/src/ui/viewer/hooks/useStats.ts b/src/ui/viewer/hooks/useStats.ts index 4eb273a7..8d13b5e6 100644 --- a/src/ui/viewer/hooks/useStats.ts +++ b/src/ui/viewer/hooks/useStats.ts @@ -10,8 +10,8 @@ export function useStats() { const response = await fetch(API_ENDPOINTS.STATS); const data = await response.json(); setStats(data); - } catch (error) { - console.error('Failed to load stats:', error); + } catch (error: unknown) { + console.error('Failed to load stats:', error instanceof Error ? error.message : String(error)); } }, []); diff --git a/src/ui/viewer/hooks/useTheme.ts b/src/ui/viewer/hooks/useTheme.ts index c81cc760..8d92fb1a 100644 --- a/src/ui/viewer/hooks/useTheme.ts +++ b/src/ui/viewer/hooks/useTheme.ts @@ -16,8 +16,8 @@ function getStoredPreference(): ThemePreference { if (stored === 'system' || stored === 'light' || stored === 'dark') { return stored; } - } catch (e) { - console.warn('Failed to read theme preference from localStorage:', e); + } catch (e: unknown) { + console.warn('Failed to read theme preference from localStorage:', e instanceof Error ? e.message : String(e)); } return 'system'; } @@ -61,8 +61,8 @@ export function useTheme() { try { localStorage.setItem(STORAGE_KEY, newPreference); setPreference(newPreference); - } catch (e) { - console.warn('Failed to save theme preference to localStorage:', e); + } catch (e: unknown) { + console.warn('Failed to save theme preference to localStorage:', e instanceof Error ? e.message : String(e)); // Still update the theme even if localStorage fails setPreference(newPreference); } diff --git a/src/utils/agents-md-utils.ts b/src/utils/agents-md-utils.ts index b3dd4a74..0fc5aae5 100644 --- a/src/utils/agents-md-utils.ts +++ b/src/utils/agents-md-utils.ts @@ -31,7 +31,7 @@ export function writeAgentsMd(agentsPath: string, context: string): void { try { writeFileSync(tempFile, finalContent); renameSync(tempFile, agentsPath); - } catch (error) { - logger.error('AGENTS_MD', 'Failed to write AGENTS.md', { agentsPath }, error as Error); + } catch (error: unknown) { + logger.error('AGENTS_MD', 'Failed to write AGENTS.md', { agentsPath }, error instanceof Error ? error : new Error(String(error))); } } diff --git a/src/utils/claude-md-utils.ts b/src/utils/claude-md-utils.ts index 2ded6b86..3b1a538c 100644 --- a/src/utils/claude-md-utils.ts +++ b/src/utils/claude-md-utils.ts @@ -439,47 +439,50 @@ export async function updateFolderClaudeMdFiles( // Process each folder for (const folderPath of folderPaths) { + let response: Response; try { // Fetch timeline via existing API (uses socket or TCP automatically) - const response = await workerHttpRequest( + response = await workerHttpRequest( `/api/search/by-file?filePath=${encodeURIComponent(folderPath)}&limit=${limit}&project=${encodeURIComponent(project)}&isFolder=true` ); - - if (!response.ok) { - logger.error('FOLDER_INDEX', 'Failed to fetch timeline', { folderPath, status: response.status }); - continue; - } - - const result = await response.json(); - if (!result.content?.[0]?.text) { - logger.debug('FOLDER_INDEX', 'No content for folder', { folderPath }); - continue; - } - - const formatted = formatTimelineForClaudeMd(result.content[0].text); - - // Fix for #794: Don't create new context files if there's no activity - // But update existing ones to show "No recent activity" if they already exist - const claudeMdPath = path.join(folderPath, targetFilename); - const hasNoActivity = formatted.includes('*No recent activity*'); - const fileExists = existsSync(claudeMdPath); - - if (hasNoActivity && !fileExists) { - logger.debug('FOLDER_INDEX', 'Skipping empty context file creation', { folderPath, targetFilename }); - continue; - } - - writeClaudeMdToFolder(folderPath, formatted, targetFilename); - - logger.debug('FOLDER_INDEX', 'Updated context file', { folderPath, targetFilename }); - } catch (error) { + } catch (error: unknown) { // Fire-and-forget: log warning but don't fail - const err = error as Error; - logger.error('FOLDER_INDEX', `Failed to update ${targetFilename}`, { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + logger.error('FOLDER_INDEX', `Failed to fetch timeline for ${targetFilename}`, { folderPath, - errorMessage: err.message, - errorStack: err.stack + errorMessage: message, + errorStack: stack }); + continue; } + + if (!response.ok) { + logger.error('FOLDER_INDEX', 'Failed to fetch timeline', { folderPath, status: response.status }); + continue; + } + + const result = await response.json() as { content?: Array<{ text?: string }> }; + if (!result.content?.[0]?.text) { + logger.debug('FOLDER_INDEX', 'No content for folder', { folderPath }); + continue; + } + + const formatted = formatTimelineForClaudeMd(result.content[0].text); + + // Fix for #794: Don't create new context files if there's no activity + // But update existing ones to show "No recent activity" if they already exist + const claudeMdPath = path.join(folderPath, targetFilename); + const hasNoActivity = formatted.includes('*No recent activity*'); + const fileExists = existsSync(claudeMdPath); + + if (hasNoActivity && !fileExists) { + logger.debug('FOLDER_INDEX', 'Skipping empty context file creation', { folderPath, targetFilename }); + continue; + } + + writeClaudeMdToFolder(folderPath, formatted, targetFilename); + + logger.debug('FOLDER_INDEX', 'Updated context file', { folderPath, targetFilename }); } } diff --git a/src/utils/json-utils.ts b/src/utils/json-utils.ts index 40b5b772..6d13d251 100644 --- a/src/utils/json-utils.ts +++ b/src/utils/json-utils.ts @@ -21,7 +21,7 @@ export function readJsonSafe(filePath: string, defaultValue: T): T { if (!existsSync(filePath)) return defaultValue; try { return JSON.parse(readFileSync(filePath, 'utf-8')); - } catch (error) { - throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}`); + } catch (error: unknown) { + throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 021ccdc5..c024e350 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -60,9 +60,9 @@ class Logger { // Create log file path with date const date = new Date().toISOString().split('T')[0]; this.logFilePath = join(logsDir, `claude-mem-${date}.log`); - } catch (error) { - // If log file initialization fails, just log to console - console.error('[LOGGER] Failed to initialize log file:', error); + } catch (error: unknown) { + // [ANTI-PATTERN IGNORED]: Logger cannot log its own failures, using stderr/console as last resort + console.error('[LOGGER] Failed to initialize log file:', error instanceof Error ? error.message : String(error)); this.logFilePath = null; } } @@ -84,8 +84,9 @@ class Logger { } else { this.level = LogLevel.INFO; } - } catch (error) { - // Fallback to INFO if settings can't be loaded + } catch (error: unknown) { + // [ANTI-PATTERN IGNORED]: Logger cannot log its own failures, using stderr/console as last resort + console.error('[LOGGER] Failed to load log level from settings:', error instanceof Error ? error.message : String(error)); this.level = LogLevel.INFO; } } @@ -152,8 +153,12 @@ class Logger { if (typeof toolInput === 'string') { try { input = JSON.parse(toolInput); - } catch { + } catch (_parseError: unknown) { + // [ANTI-PATTERN IGNORED]: Logger cannot log its own failures, using stderr/console as last resort // Input is a raw string (e.g., Bash command), use as-is + if (_parseError instanceof Error) { + console.error('[logger] JSON parse failed for tool input:', _parseError); + } input = toolInput; } } @@ -289,10 +294,10 @@ class Logger { if (this.logFilePath) { try { appendFileSync(this.logFilePath, logLine + '\n', 'utf8'); - } catch (error) { - // Logger can't log its own failures - use stderr as last resort + } catch (error: unknown) { + // [ANTI-PATTERN IGNORED]: Logger cannot log its own failures, using stderr/console as last resort // This is expected during disk full / permission errors - process.stderr.write(`[LOGGER] Failed to write to log file: ${error}\n`); + process.stderr.write(`[LOGGER] Failed to write to log file: ${error instanceof Error ? error.message : String(error)}\n`); } } else { // If no log file available, write to stderr as fallback diff --git a/src/utils/project-filter.ts b/src/utils/project-filter.ts index 1162d5b0..939faace 100644 --- a/src/utils/project-filter.ts +++ b/src/utils/project-filter.ts @@ -63,8 +63,9 @@ export function isProjectExcluded(projectPath: string, exclusionPatterns: string if (regex.test(normalizedProjectPath)) { return true; } - } catch { + } catch (error: unknown) { // Invalid pattern, skip it + console.warn(`[project-filter] Invalid exclusion pattern "${pattern}":`, error instanceof Error ? error.message : String(error)); continue; } } diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts index 5fa197d5..7d613cd4 100644 --- a/src/utils/worktree.ts +++ b/src/utils/worktree.ts @@ -38,8 +38,11 @@ export function detectWorktree(cwd: string): WorktreeInfo { let stat; try { stat = statSync(gitPath); - } catch { - // No .git at all - not a git repo + } catch (error: unknown) { + // No .git at all - not a git repo (ENOENT is expected, other errors are noteworthy) + if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[worktree] Unexpected error checking .git:`, error); + } return NOT_A_WORKTREE; } @@ -52,7 +55,8 @@ export function detectWorktree(cwd: string): WorktreeInfo { let content: string; try { content = readFileSync(gitPath, 'utf-8').trim(); - } catch { + } catch (error: unknown) { + console.warn(`[worktree] Failed to read .git file:`, error instanceof Error ? error.message : String(error)); return NOT_A_WORKTREE; }