From 6d00c1713708799e63e7e8f2db1259decc531fe1 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Mon, 6 Apr 2026 17:37:58 +0300 Subject: [PATCH] feat: setup unified classes for LLM providers and session processing, add tests for LLM unifier helper functions --- docs/backend/llm-unifier-helper.md | 461 ++++++++++++++++++ docs/testing/llm-unifier-backend-testing.md | 41 ++ package.json | 2 +- server/src/app.ts | 16 +- server/src/modules/codex/codex.routes.js | 4 +- .../conversation-search.service.ts | 233 +++++++++ .../conversations/conversations.routes.ts | 65 +++ server/src/modules/gemini/gemini.routes.js | 4 +- .../modules/llm/llm-unifier.providers.test.ts | 338 +++++++++++++ .../modules/llm/llm-unifier.sessions.test.ts | 327 +++++++++++++ server/src/modules/llm/llm.registry.ts | 42 ++ server/src/modules/llm/llm.routes.ts | 295 +++++++++++ server/src/modules/llm/llm.service.ts | 180 +++++++ .../llm/providers/abstract.provider.ts | 267 ++++++++++ .../llm/providers/base-cli.provider.ts | 284 +++++++++++ .../llm/providers/base-sdk.provider.ts | 147 ++++++ .../modules/llm/providers/claude.provider.ts | 182 +++++++ .../modules/llm/providers/codex.provider.ts | 171 +++++++ .../modules/llm/providers/cursor.provider.ts | 123 +++++ .../modules/llm/providers/gemini.provider.ts | 66 +++ .../llm/providers/provider.interface.ts | 103 ++++ .../claude.session-indexer.ts | 84 ++++ .../session-indexers/codex.session-indexer.ts | 85 ++++ .../cursor.session-indexer.ts | 138 ++++++ .../gemini.session-indexer.ts | 128 +++++ .../src/modules/llm/session-indexers/index.ts | 15 + .../session-indexer.interface.ts | 13 + .../session-indexers/session-indexer.utils.ts | 154 ++++++ server/src/modules/llm/sessions.service.ts | 236 +++++++++ server/src/modules/llm/sessions.watcher.ts | 131 +++++ .../projects/projects.inline.routes.js | 4 +- server/src/modules/providers/claude/.gitkeep | 1 - .../claude/claude.session-processor.ts | 116 ----- server/src/modules/providers/codex/.gitkeep | 1 - .../codex/codex.session-processor.ts | 178 ------- server/src/modules/providers/cursor/.gitkeep | 1 - .../cursor/cursor.session-processor.ts | 208 -------- .../gemini/gemini.session-processor.ts | 172 ------- .../providers/shared/session-parser.utils.ts | 96 ---- server/src/modules/sessions/.gitkeep | 1 - .../sessions/sessions.inline.routes.js | 226 --------- .../src/modules/sessions/sessions.service.ts | 64 --- .../src/modules/sessions/sessions.watcher.ts | 184 ------- server/src/modules/sidebar/sidebar.routes.ts | 171 ------- server/src/modules/sidebar/sidebar.service.ts | 247 ---------- .../modules/workspaces/workspaces.routes.ts | 97 ++++ .../modules/workspaces/workspaces.service.ts | 188 +++++++ server/src/runner.ts | 23 +- .../refactored/sidebar/data/workspacesApi.ts | 89 ++-- .../view/modals/ClaudeMcpFormModal.tsx | 8 +- 50 files changed, 4689 insertions(+), 1721 deletions(-) create mode 100644 docs/backend/llm-unifier-helper.md create mode 100644 docs/testing/llm-unifier-backend-testing.md create mode 100644 server/src/modules/conversations/conversation-search.service.ts create mode 100644 server/src/modules/conversations/conversations.routes.ts create mode 100644 server/src/modules/llm/llm-unifier.providers.test.ts create mode 100644 server/src/modules/llm/llm-unifier.sessions.test.ts create mode 100644 server/src/modules/llm/llm.registry.ts create mode 100644 server/src/modules/llm/llm.routes.ts create mode 100644 server/src/modules/llm/llm.service.ts create mode 100644 server/src/modules/llm/providers/abstract.provider.ts create mode 100644 server/src/modules/llm/providers/base-cli.provider.ts create mode 100644 server/src/modules/llm/providers/base-sdk.provider.ts create mode 100644 server/src/modules/llm/providers/claude.provider.ts create mode 100644 server/src/modules/llm/providers/codex.provider.ts create mode 100644 server/src/modules/llm/providers/cursor.provider.ts create mode 100644 server/src/modules/llm/providers/gemini.provider.ts create mode 100644 server/src/modules/llm/providers/provider.interface.ts create mode 100644 server/src/modules/llm/session-indexers/claude.session-indexer.ts create mode 100644 server/src/modules/llm/session-indexers/codex.session-indexer.ts create mode 100644 server/src/modules/llm/session-indexers/cursor.session-indexer.ts create mode 100644 server/src/modules/llm/session-indexers/gemini.session-indexer.ts create mode 100644 server/src/modules/llm/session-indexers/index.ts create mode 100644 server/src/modules/llm/session-indexers/session-indexer.interface.ts create mode 100644 server/src/modules/llm/session-indexers/session-indexer.utils.ts create mode 100644 server/src/modules/llm/sessions.service.ts create mode 100644 server/src/modules/llm/sessions.watcher.ts delete mode 100644 server/src/modules/providers/claude/.gitkeep delete mode 100644 server/src/modules/providers/claude/claude.session-processor.ts delete mode 100644 server/src/modules/providers/codex/.gitkeep delete mode 100644 server/src/modules/providers/codex/codex.session-processor.ts delete mode 100644 server/src/modules/providers/cursor/.gitkeep delete mode 100644 server/src/modules/providers/cursor/cursor.session-processor.ts delete mode 100644 server/src/modules/providers/gemini/gemini.session-processor.ts delete mode 100644 server/src/modules/providers/shared/session-parser.utils.ts delete mode 100644 server/src/modules/sessions/.gitkeep delete mode 100644 server/src/modules/sessions/sessions.inline.routes.js delete mode 100644 server/src/modules/sessions/sessions.service.ts delete mode 100644 server/src/modules/sessions/sessions.watcher.ts delete mode 100644 server/src/modules/sidebar/sidebar.routes.ts delete mode 100644 server/src/modules/sidebar/sidebar.service.ts create mode 100644 server/src/modules/workspaces/workspaces.routes.ts create mode 100644 server/src/modules/workspaces/workspaces.service.ts diff --git a/docs/backend/llm-unifier-helper.md b/docs/backend/llm-unifier-helper.md new file mode 100644 index 00000000..cd8e5c15 --- /dev/null +++ b/docs/backend/llm-unifier-helper.md @@ -0,0 +1,461 @@ +# How each session processes sessions +- The way each session processes the sessions is already setup in `server/src/modules/providers`. Port over the existing logic to the new classes if possible. + +# How to start, resume, and stop a session + +## Claude +A new session is created by calling `query({ prompt, options })` which yields an async stream of SDK messages. The session ID can be provided explicitly by using `resume` option and passing the session id (`sdkOptions.resume = sessionId;`). + +https://platform.claude.com/docs/en/agent-sdk/typescript#types + +Session can be stopped midway using `queryInstance.interrupt()` + https://platform.claude.com/docs/en/agent-sdk/typescript#methods + +## Codex +- Starting - `const thread = codex.startThread(threadOptions)` +- Resuming - `codex.resumeThread(sessionId, threadOptions);` +- Stop a session + ``` +     // Execute with streaming + +    const streamedTurn = await thread.runStreamed(command, { + +      signal: abortController.signal + +    }); + ``` +### About Abort controllers +- Think of `AbortController` as a **cancel button for async work**. +- **Controller** = thing that sends the cancel command. +- **Signal** = thing that receives or carries the cancel state + +```js +const controller = new AbortController(); + +fetch("https://api.example.com/data", { + signal: controller.signal +}) + .then(response => response.json()) + .then(data => { + console.log("Finished:", data); + }) + .catch(error => { + if (error.name === "AbortError") { + console.log("The request was cancelled"); + } else { + console.error("Real error:", error); + } + }); + +// Cancel it after 2 seconds +setTimeout(() => { + controller.abort(); +}, 2000); +``` +- `AbortController` does **not magically stop all JavaScript everywhere**. It only works if the API or function you are using actually supports cancellation via a signal. `fetch` does. Your own custom async functions can too, but you have to write that support yourself. In codex, the method `runStreamed` supports it as well. +```js +function wait(ms, { signal } = {}) { + return new Promise((resolve, reject) => { + // if signal was aborted EVEN BEFORE the function started, return back. + // This catches the case where someone did this first: + // controller.abort("Cancelled already"); + // wait(5000, { signal: controller.signal }); + if (signal?.aborted) { + reject(signal.reason); // it supports custom reasoning as well. + return; + } + + const timeoutId = setTimeout(() => { + resolve("Done waiting"); + }, ms); + + // when the signal.abort event is fired (when controller.abort() is called somewhere else), it sends an `abort` event. + // When we get this, remove the timeoutId + signal?.addEventListener("abort", () => { + clearTimeout(timeoutId); + reject(signal.reason); + }); + }); +} + + +// ---------------- USAGE -------------------- +const controller = new AbortController(); + +wait(5000, { signal: controller.signal }) + .then(result => { + console.log(result); + }) + .catch(error => { + console.log("Cancelled:", error); + }); + +setTimeout(() => { + controller.abort("User cancelled the wait"); +}, 1000); + +``` + + +## Gemini +### Start + +spawn `gemini --prompt "actualprompt" --model "actual model", --output-format 'stream-json'` + +- Stream `json` output format send responses in terms of a series of `json` chunks. If we store it, we would use .`jsonl` format. +- Allowed tools aren't needed as it's depreciated. +``` + --allowed-tools [DEPRECATED: Use Policy Engine instead See + https://geminicli.com/docs/core/policy-engine] Tools that are allowed + to run without confirmation +``` + +- `--prompt` allows us to run just one prompt in headless mode. It will automatically trust the workspace directory so it won't ask us whether we trust the workspace or not. + +### Stop/Abort a session +```js +try { + geminiProc.kill('SIGTERM'); // gracefully terminates the process. It ASKS the process to shut down cleanly. The process can catch it, save state, close files, and exit + setTimeout(() => { + geminiProc.kill('SIGKILL'); // kills it immediately + } + }, 2000); // Wait 2 seconds before force kill + + return true; +} catch (error) { + return false; +} +``` + +### resume +- spawn `gemini --resume ` + +### To receive a response +``` +child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + ... +}) + +child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + ... +} +``` + +## Cursor +### Start +- spawn `cursor-agent --print --trust --output-format 'stream-json' ` +This won't be able to run shell commands like `git init`. To be able to run those, `--yolo` must be passed. + +### Resume +- spawn `cursor-agent --resume ` + +### abort +- same approach as gemini. + + +# How to fetch (list the model types supported for each model...find out if there is an easy way to fetch automatically from the files) + +## Claude + +`query.supportedModels()` returns `ModelInfo[]`. +```ts +/** + * Information about an available model. + */ +export declare type ModelInfo = { + /** + * Model identifier to use in API calls + */ + value: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the model's capabilities + */ + description: string; + /** + * Whether this model supports effort levels + */ + supportsEffort?: boolean; + /** + * Available effort levels for this model + */ + supportedEffortLevels?: ('low' | 'medium' | 'high' | 'max')[]; + /** + * Whether this model supports adaptive thinking (Claude decides when and how much to think) + */ + supportsAdaptiveThinking?: boolean; +}; +``` + +``` +supported models = [ + { + value: 'default', + displayName: 'Default (recommended)', + description: 'Use the default model (currently Sonnet 4.6) · $3/$15 per Mtok', + supportsEffort: true, + supportedEffortLevels: [ 'low', 'medium', 'high', 'max' ], + supportsAdaptiveThinking: true + }, + { + value: 'sonnet[1m]', + displayName: 'Sonnet (1M context)', + description: 'Sonnet 4.6 for long sessions · $6/$22.50 per Mtok', + supportsEffort: true, + supportedEffortLevels: [ 'low', 'medium', 'high', 'max' ], + supportsAdaptiveThinking: true + }, + { + value: 'opus', + displayName: 'Opus', + description: 'Opus 4.6 · Most capable for complex work · $5/$25 per Mtok', + supportsEffort: true, + supportedEffortLevels: [ 'low', 'medium', 'high', 'max' ], + supportsAdaptiveThinking: true + }, + { + value: 'opus[1m]', + displayName: 'Opus (1M context)', + description: 'Opus 4.6 for long sessions · $10/$37.50 per Mtok', + supportsEffort: true, + supportedEffortLevels: [ 'low', 'medium', 'high', 'max' ], + supportsAdaptiveThinking: true + }, + { + value: 'haiku', + displayName: 'Haiku', + description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok' + }, + { + value: 'sonnet', + displayName: 'sonnet', + description: 'Custom model', + supportsEffort: true, + supportedEffortLevels: [ 'low', 'medium', 'high', 'max' ], + supportsAdaptiveThinking: true + } +] +``` + +## Codex + +- Found in `.codex/models_cache.json`. It's in the `models` attribute. +```json +{ + ..., + "models": [ + { + "slug": "gpt-5.4", + "display_name": "gpt-5.4", + "description": "Latest frontier agentic coding model.", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "availability_nux": null, + "upgrade": null, + "base_instructions": "...", + "model_messages": { + "instructions_template": "...", + "instructions_variables": { + "personality_default": "", + "personality_friendly": "..." + } + }, + "supports_reasoning_summaries": true, + "default_reasoning_summary": "none", + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_parallel_tool_calls": true, + "supports_image_detail_original": true, + "context_window": 272000, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text", + "image" + ], + "supports_search_tool": true + }, + { + ... + } + ] +} +``` + +## Gemini +- There is no way to automatically do this. So, use this +![[Pasted image 20260401124033.png]] + +The above is for free one. The below contains for all. + +``` + OPTIONS: [ + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, + { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, + { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' } + ], +``` + +## Cursor +- spawn `cursor-agent --list-models` and parse the ANSI output. +```js +function parseModelLine(line) { + const trimmed = line.trim(); + + if (!trimmed || trimmed === 'Available models' || trimmed.startsWith('Loading models') || trimmed.startsWith('Tip:')) { + return null; + } + + const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/); + if (!match) { + return null; + } + + const name = match[1].trim(); + let description = match[2].trim(); + const current = /\(current\)/i.test(description); + const defaultModel = /\(default\)/i.test(description); + + description = description.replace(/\s*\((current|default)\)/gi, '').replace(/\s{2,}/g, ' ').trim(); + + return { + name, + description, + current, + default: defaultModel, + }; +} + +function parseModelsOutput(text) { + const models = []; + + for (const line of stripAnsi(text).split(/\r?\n/)) { + const parsed = parseModelLine(line); + if (parsed) { + models.push(parsed); + } + } + + return models; +} + + +// ------------ tHE ABOVE RETURNS ------------ +[ + { + "name": "auto", + "description": "Auto", + "current": true, + "default": false + }, + { + "name": "composer-2-fast", + "description": "Composer 2 Fast", + "current": false, + "default": true + }, + { + "name": "composer-2", + "description": "Composer 2", + "current": false, + "default": false + }, + ... +] +``` + +# How to fetch session history +- In the sessions table, there is a `jsonl_path` column. Go to directly that and parse the JSONLs from there. For `gemini`, the `jsonl_path` actually points to a gemini JSON file (since Gemini stores information in JSON rather than JSONL). DON'T use the LEGACY fetcher. + +# How to search conversations for each provider +- Go to all the JSONL path directories from the database and use `@vscode/ripgrep` library for searching something. + + +# How to change thinking modes for each model +## Claude +- Passed through `query` options through `effort: <'low' | 'medium' | 'high' | 'max'>` + +Default is high. + +## Codex +- passed through `threadOptions` + +``` + +type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; + +type ThreadOptions = { + model?: string; + sandboxMode?: SandboxMode; + workingDirectory?: string; + skipGitRepoCheck?: boolean; + modelReasoningEffort?: ModelReasoningEffort; + networkAccessEnabled?: boolean; + webSearchMode?: WebSearchMode; + webSearchEnabled?: boolean; + approvalPolicy?: ApprovalMode; + additionalDirectories?: string[]; +}; + + +``` +- `minimal` is supported only by `GPT-5` + +## Gemini +- Not changeable. We can only select the different providers that have different thinking levels by themselves. + +## Cursor +- Same as gemini. + + +# How to set/change models at start/after a session response respectively? +## Claude +- Initially can be set at start using `queryOptions.model` +- Just resume the session by updating the model in `threadoptions` + +## Codex +- Same as claude + +## Gemini +- Just add the `--model ` property in the new spawned command. If there is something to resume, add `--resume ` +## Cursor +- Just add the `--model ` property in the new spawned command. If there is something to resume, add `--resume `. In other words, same as gemini. + diff --git a/docs/testing/llm-unifier-backend-testing.md b/docs/testing/llm-unifier-backend-testing.md new file mode 100644 index 00000000..41aa4a89 --- /dev/null +++ b/docs/testing/llm-unifier-backend-testing.md @@ -0,0 +1,41 @@ +# LLM Unifier Backend Testing Report + +Date: 2026-04-06 + +## Scope +This report validates the backend functionality checklist in `docs/backend/llm-unifier-helper.md`. + +## Test Files Added +- `server/src/modules/llm/llm-unifier.providers.test.ts` +- `server/src/modules/llm/llm-unifier.sessions.test.ts` + +Each test case includes an inline comment describing which helper requirement it covers. + +## Command Used +```powershell +$env:TSX_TSCONFIG_PATH='server/tsconfig.json'; npm run test:server -- server/src/modules/llm/llm-unifier.providers.test.ts server/src/modules/llm/llm-unifier.sessions.test.ts +``` + +## Result +- Total tests: 32 +- Passed: 32 +- Failed: 0 + +## Requirement Coverage Matrix +| Helper requirement | Coverage | +| --- | --- | +| Session processing logic orchestration | `llmSessionsService.synchronizeSessions aggregates processed counts and failures`, `llmSessionsService.synchronizeProvider honors fullRescan option` | +| Start/resume behavior: Cursor | `cursor provider builds start/resume CLI invocations correctly` | +| Start/resume behavior: Gemini | `gemini provider builds start/resume CLI invocations and exposes curated models` | +| Start/resume/stop behavior: Codex (`startThread`, `resumeThread`, abort controller) | `codex provider start/resume use correct SDK thread methods and stop aborts signal` | +| Claude helper behavior (effort mapping, runtime permission handler, event normalization) | `claude provider helper mappings match unifier contract` | +| Model listing: Cursor (`--list-models` parsing) | `cursor provider parses model list output into normalized models` | +| Model listing: Gemini (curated options) | `gemini provider builds start/resume CLI invocations and exposes curated models` | +| Model listing: Codex (`~/.codex/models_cache.json`) | `codex provider reads models_cache.json and maps model metadata` | +| Runtime permission/thinking support constraints | `llmService rejects unsupported runtime permission and thinking mode combinations`, `providers enforce capability gates for model/thinking updates` | +| Thinking mode + model preference persistence across launches | `codex provider applies saved model/thinking preferences on subsequent launch` | +| Session history from DB `jsonl_path` (JSONL + Gemini JSON), no legacy fetcher path | `llmSessionsService.getSessionHistory parses JSONL and Gemini JSON correctly` | +| Session artifact deletion using processor path | `llmSessionsService.deleteSessionArtifacts validates ids and deletes disk/db artifacts` | +| Session rename/update path | `llmSessionsService.updateSessionCustomName validates existence before updating` | +| Conversation search over indexed transcript paths with provider/case filters | `conversationSearchService searches indexed transcripts with provider and case filters` | + diff --git a/package.json b/package.json index 1f3cb053..1d5e7c38 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "preview": "vite preview", "typecheck:client": "tsc --noEmit -p tsconfig.json", "typecheck:server": "tsc --noEmit -p server/tsconfig.json", - "test:server": "node --import tsx --test server/src/shared/platform/runtime-platform.test.ts server/src/shared/platform/text.test.ts server/src/shared/platform/stream.test.ts server/src/shared/platform/path.test.ts server/src/shared/platform/shell.test.ts", + "test:server": "tsx --tsconfig server/tsconfig.json --test server/src/modules/llm/llm-session-processor.service.test.ts server/src/modules/llm/llm-unifier.providers.test.ts", "verify:server": "npm run typecheck:server && npm run test:server && npm run server:build", "typecheck": "npm run typecheck:client && npm run typecheck:server", "lint": "eslint src/", diff --git a/server/src/app.ts b/server/src/app.ts index a51bccb4..55e6395d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,19 +14,19 @@ export function createServerApplication(): ServerApplication { start: async () => { // ---------------------------------------------- // Legacy backend Runner - logger.info('Bootstrapping backend via legacy runtime bridge', { - legacyRuntime: runtimePaths.legacyRuntimePath, - }); - await import(pathToFileURL(runtimePaths.legacyRuntimePath).href); + // logger.info('Bootstrapping backend via legacy runtime bridge', { + // legacyRuntime: runtimePaths.legacyRuntimePath, + // }); + // await import(pathToFileURL(runtimePaths.legacyRuntimePath).href); // ---------------------------------------------- // ---------------------------------------------- // Refactor backend Runner - // logger.info('Bootstrapping backend via refactor runtime', { - // refactorRuntime: runtimePaths.refactorRuntimePath, - // }); - // await import(pathToFileURL(runtimePaths.refactorRuntimePath).href); + logger.info('Bootstrapping backend via refactor runtime', { + refactorRuntime: runtimePaths.refactorRuntimePath, + }); + await import(pathToFileURL(runtimePaths.refactorRuntimePath).href); }, }; } diff --git a/server/src/modules/codex/codex.routes.js b/server/src/modules/codex/codex.routes.js index f61571e3..dfe3f40f 100644 --- a/server/src/modules/codex/codex.routes.js +++ b/server/src/modules/codex/codex.routes.js @@ -6,7 +6,7 @@ import os from 'os'; import TOML from '@iarna/toml'; import { getCodexSessions } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; const router = express.Router(); @@ -72,7 +72,7 @@ router.get('/sessions', async (req, res) => { router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; - await deleteSessionFromProviders(sessionId); + await llmSessionsService.deleteSessionArtifacts(sessionId); res.json({ success: true }); } catch (error) { console.error(`Error deleting Codex session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/conversations/conversation-search.service.ts b/server/src/modules/conversations/conversation-search.service.ts new file mode 100644 index 00000000..611f98c6 --- /dev/null +++ b/server/src/modules/conversations/conversation-search.service.ts @@ -0,0 +1,233 @@ +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { once } from 'node:events'; + +import spawn from 'cross-spawn'; + +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { AppError } from '@/shared/utils/app-error.js'; + +type SearchResult = { + sessionId: string; + provider: string; + filePath: string; + lineNumber: number; + lineText: string; +}; + +type SearchInput = { + query: string; + provider?: string; + caseSensitive?: boolean; + limit?: number; +}; + +/** + * Normalizes file paths so DB session paths match ripgrep paths across platforms. + */ +const normalizePathForLookup = (filePath: string): string => + process.platform === 'win32' ? path.normalize(filePath).toLowerCase() : path.normalize(filePath); + +/** + * Searches all indexed session transcript files for a text query. + */ +export const conversationSearchService = { + /** + * Uses ripgrep first for speed, then falls back to direct file scanning. + */ + async search(input: SearchInput): Promise { + const query = input.query.trim(); + if (!query) { + throw new AppError('query is required.', { + code: 'SEARCH_QUERY_REQUIRED', + statusCode: 400, + }); + } + + const limit = Math.min(Math.max(input.limit ?? 50, 1), 500); + const allSessions = sessionsDb + .getAllSessions() + .filter((session) => Boolean(session.jsonl_path)) + .filter((session) => (input.provider ? session.provider === input.provider : true)); + + if (allSessions.length === 0) { + return []; + } + + const sessionByFile = new Map( + allSessions + .filter((session): session is typeof session & { jsonl_path: string } => Boolean(session.jsonl_path)) + .map((session) => [normalizePathForLookup(session.jsonl_path), session]), + ); + + const uniqueDirectories = [...new Set(allSessions.map((session) => path.dirname(session.jsonl_path as string)))]; + const rgResults = await runRipgrepSearch(query, uniqueDirectories, { + caseSensitive: input.caseSensitive ?? false, + limit, + }); + + if (rgResults.length > 0) { + const mappedResults: SearchResult[] = []; + + for (const match of rgResults) { + const session = sessionByFile.get(normalizePathForLookup(match.filePath)); + if (!session) { + continue; + } + + mappedResults.push({ + sessionId: session.session_id, + provider: session.provider, + filePath: match.filePath, + lineNumber: match.lineNumber, + lineText: match.lineText, + }); + + if (mappedResults.length >= limit) { + break; + } + } + + return mappedResults; + } + + return fallbackFileSearch(query, sessionByFile, { + caseSensitive: input.caseSensitive ?? false, + limit, + }); + }, +}; + +/** + * Runs ripgrep in JSON mode and maps each match to a minimal search shape. + */ +async function runRipgrepSearch( + query: string, + directories: string[], + options: { + caseSensitive: boolean; + limit: number; + }, +): Promise> { + const args = ['--json', '--line-number', '--no-heading']; + + if (!options.caseSensitive) { + args.push('-i'); + } + + args.push('--max-count', String(options.limit), '--', query, ...directories); + + const child = spawn('rg', args, { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: process.cwd(), + env: process.env, + }); + + let stdout = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + try { + const closePromise = once(child, 'close'); + const errorPromise = once(child, 'error').then(([error]) => { + throw error; + }); + await Promise.race([closePromise, errorPromise]); + } catch { + return []; + } + + if (child.exitCode !== 0 && child.exitCode !== 1) { + return []; + } + + const matches: Array<{ filePath: string; lineNumber: number; lineText: string }> = []; + + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (parsed?.type !== 'match') { + continue; + } + + const filePath = parsed?.data?.path?.text; + const lineNumber = parsed?.data?.line_number; + const lineText = parsed?.data?.lines?.text; + + if ( + typeof filePath !== 'string' || + typeof lineNumber !== 'number' || + typeof lineText !== 'string' + ) { + continue; + } + + matches.push({ + filePath, + lineNumber, + lineText: lineText.trimEnd(), + }); + + if (matches.length >= options.limit) { + break; + } + } + + return matches; +} + +/** + * Fallback search path when ripgrep is unavailable or returns no structured matches. + */ +async function fallbackFileSearch( + query: string, + sessionByFile: Map, + options: { + caseSensitive: boolean; + limit: number; + }, +): Promise { + const results: SearchResult[] = []; + const queryForMatch = options.caseSensitive ? query : query.toLowerCase(); + + for (const [, session] of sessionByFile) { + if (!session.jsonl_path) { + continue; + } + + const content = await readFile(session.jsonl_path, 'utf8'); + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const source = options.caseSensitive ? line : line.toLowerCase(); + + if (!source.includes(queryForMatch)) { + continue; + } + + results.push({ + sessionId: session.session_id, + provider: session.provider, + filePath: session.jsonl_path, + lineNumber: index + 1, + lineText: line, + }); + + if (results.length >= options.limit) { + return results; + } + } + } + + return results; +} diff --git a/server/src/modules/conversations/conversations.routes.ts b/server/src/modules/conversations/conversations.routes.ts new file mode 100644 index 00000000..05cf0fec --- /dev/null +++ b/server/src/modules/conversations/conversations.routes.ts @@ -0,0 +1,65 @@ +import express, { type NextFunction, type Request, type Response } from 'express'; + +import { asyncHandler } from '@/shared/http/async-handler.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js'; +import { logger } from '@/shared/utils/logger.js'; +import { conversationSearchService } from '@/modules/conversations/conversation-search.service.js'; + +const router = express.Router(); + +router.get( + '/search', + asyncHandler(async (req: Request, res: Response) => { + const query = typeof req.query.query === 'string' ? req.query.query : ''; + const provider = + typeof req.query.provider === 'string' ? req.query.provider.trim().toLowerCase() : undefined; + const caseSensitive = req.query.caseSensitive === 'true'; + const limit = + typeof req.query.limit === 'string' && Number.isFinite(Number.parseInt(req.query.limit, 10)) + ? Number.parseInt(req.query.limit, 10) + : undefined; + + const results = await conversationSearchService.search({ + query, + provider, + caseSensitive, + limit, + }); + + res.json( + createApiSuccessResponse({ + query, + provider: provider ?? null, + count: results.length, + results, + }), + ); + }), +); + +/** + * Normalizes route-level failures to a consistent JSON API shape. + */ +router.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (res.headersSent) { + return; + } + + if (error instanceof AppError) { + res + .status(error.statusCode) + .json(createApiErrorResponse(error.code, error.message, undefined, error.details)); + return; + } + + const message = + error instanceof Error ? error.message : 'Unexpected conversations route failure.'; + logger.error(message, { + module: 'conversations.routes', + }); + + res.status(500).json(createApiErrorResponse('INTERNAL_ERROR', message)); +}); + +export default router; diff --git a/server/src/modules/gemini/gemini.routes.js b/server/src/modules/gemini/gemini.routes.js index 1b9b1588..5bf30750 100644 --- a/server/src/modules/gemini/gemini.routes.js +++ b/server/src/modules/gemini/gemini.routes.js @@ -1,6 +1,6 @@ import express from 'express'; import sessionManager from '../../../sessionManager.js'; -import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; const router = express.Router(); @@ -13,7 +13,7 @@ router.delete('/sessions/:sessionId', async (req, res) => { } await sessionManager.deleteSession(sessionId); - await deleteSessionFromProviders(sessionId); + await llmSessionsService.deleteSessionArtifacts(sessionId); res.json({ success: true }); } catch (error) { console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/llm/llm-unifier.providers.test.ts b/server/src/modules/llm/llm-unifier.providers.test.ts new file mode 100644 index 00000000..edaa7b1e --- /dev/null +++ b/server/src/modules/llm/llm-unifier.providers.test.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { AppError } from '../../shared/utils/app-error.js'; +import { llmService } from './llm.service.js'; +import { CursorProvider } from './providers/cursor.provider.js'; +import { GeminiProvider } from './providers/gemini.provider.js'; +import { CodexProvider } from './providers/codex.provider.js'; +import { ClaudeProvider } from './providers/claude.provider.js'; + +const asyncEvents = async function* (events: unknown[]) { + for (const event of events) { + yield event; + } +}; + +// This test covers Cursor start/resume command construction, including yolo/model/resume flags. +test('cursor provider builds start/resume CLI invocations correctly', () => { + const provider = new CursorProvider() as any; + + const start = provider.createCliInvocation({ + prompt: 'build feature', + sessionId: 'cursor-session-1', + isResume: false, + model: 'composer-2', + allowYolo: true, + workspacePath: '/tmp/workspace', + }); + assert.equal(start.command, 'cursor-agent'); + assert.deepEqual(start.args, [ + '--print', + '--trust', + '--output-format', + 'stream-json', + '--yolo', + '--model', + 'composer-2', + 'build feature', + ]); + + const resume = provider.createCliInvocation({ + prompt: 'continue', + sessionId: 'cursor-session-1', + isResume: true, + workspacePath: '/tmp/workspace', + }); + assert.equal(resume.command, 'cursor-agent'); + assert.deepEqual(resume.args, [ + '--print', + '--trust', + '--output-format', + 'stream-json', + '--resume', + 'cursor-session-1', + 'continue', + ]); +}); + +// This test covers Cursor model-list parsing, including ANSI stripping and current/default flags. +test('cursor provider parses model list output into normalized models', async () => { + const provider = new CursorProvider() as any; + + provider.runCommandForOutput = async () => [ + '\u001b[32mAvailable models\u001b[0m', + 'auto - Auto (current)', + 'composer-2-fast - Composer 2 Fast (default)', + 'Tip: use --model', + ].join('\n'); + + const models = await provider.listModels(); + assert.equal(models.length, 2); + assert.deepEqual(models[0], { + value: 'auto', + displayName: 'auto', + description: 'Auto', + current: true, + default: false, + supportsThinkingModes: false, + supportedThinkingModes: [], + }); + assert.equal(models[1].value, 'composer-2-fast'); + assert.equal(models[1].default, true); +}); + +// This test covers Gemini start/resume CLI construction and curated model list contract. +test('gemini provider builds start/resume CLI invocations and exposes curated models', async () => { + const provider = new GeminiProvider() as any; + + const start = provider.createCliInvocation({ + prompt: 'explain architecture', + sessionId: 'gemini-session-1', + isResume: false, + model: 'gemini-2.5-pro', + workspacePath: '/tmp/workspace', + }); + assert.equal(start.command, 'gemini'); + assert.deepEqual(start.args, [ + '--prompt', + 'explain architecture', + '--output-format', + 'stream-json', + '--model', + 'gemini-2.5-pro', + ]); + + const resume = provider.createCliInvocation({ + prompt: 'continue', + sessionId: 'gemini-session-1', + isResume: true, + workspacePath: '/tmp/workspace', + }); + assert.deepEqual(resume.args, [ + '--prompt', + 'continue', + '--output-format', + 'stream-json', + '--resume', + 'gemini-session-1', + ]); + + const models = await provider.listModels(); + assert.ok(models.some((model: { value?: string }) => model.value === 'gemini-2.5-pro')); +}); + +// This test covers Codex start/resume behavior and abort-controller based stop behavior. +test('codex provider start/resume use correct SDK thread methods and stop aborts signal', async () => { + const provider = new CodexProvider() as any; + + const calls: Array<{ fn: 'start' | 'resume'; sessionId?: string; options: Record }> = []; + let capturedSignal: AbortSignal | undefined; + + const fakeThread = { + async runStreamed(_prompt: string, options?: { signal?: AbortSignal }) { + capturedSignal = options?.signal; + return { events: asyncEvents([{ type: 'chunk' }]) }; + }, + }; + + provider.loadCodexSdkModule = async () => ({ + Codex: class { + startThread(options?: Record) { + calls.push({ fn: 'start', options: options ?? {} }); + return fakeThread; + } + + resumeThread(sessionId: string, options?: Record) { + calls.push({ fn: 'resume', sessionId, options: options ?? {} }); + return fakeThread; + } + }, + }); + + const startExec = await provider.createSdkExecution({ + prompt: 'start codex', + sessionId: 'codex-session-1', + isResume: false, + model: 'gpt-5.4', + thinkingMode: 'high', + workspacePath: '/tmp/workspace', + }); + assert.equal(calls[0]?.fn, 'start'); + assert.equal(calls[0]?.options.model, 'gpt-5.4'); + assert.equal(calls[0]?.options.modelReasoningEffort, 'high'); + assert.equal(calls[0]?.options.workingDirectory, '/tmp/workspace'); + + assert.equal(await startExec.stop(), true); + assert.equal(capturedSignal?.aborted, true); + + await provider.createSdkExecution({ + prompt: 'resume codex', + sessionId: 'codex-session-1', + isResume: true, + workspacePath: '/tmp/workspace', + }); + assert.equal(calls[1]?.fn, 'resume'); + assert.equal(calls[1]?.sessionId, 'codex-session-1'); +}); + +// This test covers Codex model-list loading from ~/.codex/models_cache.json and model-shape mapping. +test('codex provider reads models_cache.json and maps model metadata', async () => { + const provider = new CodexProvider(); + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-models-')); + const codexDir = path.join(tempRoot, '.codex'); + await fs.mkdir(codexDir, { recursive: true }); + await fs.writeFile( + path.join(codexDir, 'models_cache.json'), + JSON.stringify({ + models: [ + { + slug: 'gpt-5.4', + display_name: 'GPT-5.4', + description: 'Latest frontier agentic coding model.', + priority: 1, + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'medium' }, + { effort: 'high' }, + ], + }, + ], + }), + 'utf8', + ); + + const originalHomeDir = os.homedir; + (os as any).homedir = () => tempRoot; + + try { + const models = await provider.listModels(); + assert.equal(models.length, 1); + assert.equal(models[0]?.value, 'gpt-5.4'); + assert.equal(models[0]?.default, true); + assert.deepEqual(models[0]?.supportedThinkingModes, ['low', 'medium', 'high']); + } finally { + (os as any).homedir = originalHomeDir; + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +// This test covers persisted session-level model/thinking preferences flowing into Codex thread options. +test('codex provider applies saved model/thinking preferences on subsequent launch', async () => { + const provider = new CodexProvider() as any; + let threadOptions: Record | null = null; + + provider.loadCodexSdkModule = async () => ({ + Codex: class { + startThread(options?: Record) { + threadOptions = options ?? null; + return { + async runStreamed() { + return { events: asyncEvents([]) }; + }, + }; + } + + resumeThread() { + return { + async runStreamed() { + return { events: asyncEvents([]) }; + }, + }; + } + }, + }); + + await provider.setSessionModel('codex-pref-1', 'gpt-5.4'); + await provider.setSessionThinkingMode('codex-pref-1', 'xhigh'); + + await provider.launchSession({ + prompt: 'use stored preferences', + sessionId: 'codex-pref-1', + }); + + assert.ok(threadOptions); + assert.equal((threadOptions as { model?: string }).model, 'gpt-5.4'); + assert.equal((threadOptions as { modelReasoningEffort?: string }).modelReasoningEffort, 'xhigh'); +}); + +// This test covers Claude thinking-level mapping, runtime permission handlers, and model/event normalization. +test('claude provider helper mappings match unifier contract', async () => { + const provider = new ClaudeProvider() as any; + + assert.equal(provider.resolveClaudeEffort(undefined), 'high'); + assert.equal(provider.resolveClaudeEffort('low'), 'low'); + assert.equal(provider.resolveClaudeEffort('not-real'), 'high'); + + const allowHandler = provider.resolvePermissionHandler('allow'); + const denyHandler = provider.resolvePermissionHandler('deny'); + const askHandler = provider.resolvePermissionHandler('ask'); + assert.equal(typeof allowHandler, 'function'); + assert.equal(typeof denyHandler, 'function'); + assert.equal(askHandler, undefined); + + const allowResult = await allowHandler?.(); + const denyResult = await denyHandler?.(); + assert.deepEqual(allowResult, { behavior: 'allow' }); + assert.equal(denyResult?.behavior, 'deny'); + + const mappedModel = provider.mapModelInfo({ + value: 'default', + displayName: 'Default', + description: 'Default Claude model', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'max'], + }); + assert.equal(mappedModel.value, 'default'); + assert.equal(mappedModel.supportsThinkingModes, true); + assert.deepEqual(mappedModel.supportedThinkingModes, ['low', 'medium', 'high', 'max']); + + const mappedEvent = provider.mapSdkEvent({ type: 'message', subtype: 'delta' }); + assert.equal(mappedEvent?.message, 'message:delta'); +}); + +// This test covers service-level capability validation for runtime permissions and thinking mode support. +test('llmService rejects unsupported runtime permission and thinking mode combinations', async () => { + await assert.rejects( + llmService.startSession('cursor', { + prompt: 'hello', + runtimePermissionMode: 'allow', + }), + (error: unknown) => + error instanceof AppError && + error.code === 'RUNTIME_PERMISSION_NOT_SUPPORTED' && + error.statusCode === 400, + ); + + await assert.rejects( + llmService.startSession('cursor', { + prompt: 'hello', + thinkingMode: 'high', + }), + (error: unknown) => + error instanceof AppError && + error.code === 'THINKING_MODE_NOT_SUPPORTED' && + error.statusCode === 400, + ); +}); + +// This test covers model/thinking capability gates on providers before any external process/SDK usage. +test('providers enforce capability gates for model/thinking updates', async () => { + const claudeProvider = new ClaudeProvider(); + const cursorProvider = new CursorProvider(); + + await assert.rejects( + cursorProvider.setSessionThinkingMode('cursor-session', 'high'), + (error: unknown) => + error instanceof AppError && + error.code === 'THINKING_MODE_NOT_SUPPORTED' && + error.statusCode === 400, + ); + + await claudeProvider.setSessionModel('claude-session', 'sonnet'); + const preference = (claudeProvider as any).getSessionPreference('claude-session'); + assert.equal(preference.model, 'sonnet'); +}); diff --git a/server/src/modules/llm/llm-unifier.sessions.test.ts b/server/src/modules/llm/llm-unifier.sessions.test.ts new file mode 100644 index 00000000..24f26333 --- /dev/null +++ b/server/src/modules/llm/llm-unifier.sessions.test.ts @@ -0,0 +1,327 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { AppError } from '../../shared/utils/app-error.js'; +import { scanStateDb } from '../../shared/database/repositories/scan-state.db.js'; +import { sessionsDb } from '../../shared/database/repositories/sessions.db.js'; +import { llmSessionsService } from './sessions.service.js'; +import { sessionIndexers } from './session-indexers/index.js'; +import { conversationSearchService } from '../conversations/conversation-search.service.js'; +import type { ISessionIndexer } from './session-indexers/session-indexer.interface.js'; + +const patchMethod = (target: T, key: K, replacement: T[K]) => { + const original = target[key]; + (target as any)[key] = replacement; + return () => { + (target as any)[key] = original; + }; +}; + +const patchIndexers = (nextIndexers: ISessionIndexer[]) => { + const originalIndexers = [...sessionIndexers]; + sessionIndexers.splice(0, sessionIndexers.length, ...nextIndexers); + return () => { + sessionIndexers.splice(0, sessionIndexers.length, ...originalIndexers); + }; +}; + +// This test covers multi-provider synchronization orchestration and failure aggregation. +test('llmSessionsService.synchronizeSessions aggregates processed counts and failures', { concurrency: false }, async () => { + let updateLastScannedAtCalls = 0; + const restoreScanDate = patchMethod(scanStateDb, 'getLastScannedAt', () => new Date('2026-04-01T00:00:00.000Z')); + const restoreUpdateScanDate = patchMethod(scanStateDb, 'updateLastScannedAt', () => { + updateLastScannedAtCalls += 1; + }); + const restoreIndexers = patchIndexers([ + { + provider: 'claude', + async synchronize() { + return 3; + }, + }, + { + provider: 'codex', + async synchronize() { + throw new Error('codex index failed'); + }, + }, + ]); + + try { + const result = await llmSessionsService.synchronizeSessions(); + assert.equal(result.processedByProvider.claude, 3); + assert.equal(result.processedByProvider.codex, 0); + assert.equal(result.processedByProvider.cursor, 0); + assert.equal(result.processedByProvider.gemini, 0); + assert.equal(result.failures.length, 1); + assert.equal(result.failures[0], 'codex index failed'); + assert.equal(updateLastScannedAtCalls, 1); + } finally { + restoreIndexers(); + restoreUpdateScanDate(); + restoreScanDate(); + } +}); + +// This test covers provider-specific sync behavior for both incremental and full-rescan modes. +test('llmSessionsService.synchronizeProvider honors fullRescan option', { concurrency: false }, async () => { + const observedScanDates: Array = []; + const restoreScanDate = patchMethod(scanStateDb, 'getLastScannedAt', () => new Date('2026-04-02T00:00:00.000Z')); + const restoreUpdateScanDate = patchMethod(scanStateDb, 'updateLastScannedAt', () => {}); + const restoreIndexers = patchIndexers([ + { + provider: 'cursor', + async synchronize(lastScanAt) { + observedScanDates.push(lastScanAt); + return 7; + }, + }, + ]); + + try { + const incremental = await llmSessionsService.synchronizeProvider('cursor'); + const fullRescan = await llmSessionsService.synchronizeProvider('cursor', { fullRescan: true }); + + assert.equal(incremental.provider, 'cursor'); + assert.equal(incremental.processed, 7); + assert.equal(fullRescan.provider, 'cursor'); + assert.equal(fullRescan.processed, 7); + assert.equal(observedScanDates.length, 2); + assert.ok(observedScanDates[0] instanceof Date); + assert.equal(observedScanDates[1], null); + } finally { + restoreIndexers(); + restoreUpdateScanDate(); + restoreScanDate(); + } +}); + +// This test covers session rename persistence and not-found guardrails. +test('llmSessionsService.updateSessionCustomName validates existence before updating', { concurrency: false }, () => { + let updated: { sessionId: string; customName: string } | null = null; + const restoreGetById = patchMethod(sessionsDb, 'getSessionById', (sessionId: string) => ( + sessionId === 'known-session' + ? { + session_id: 'known-session', + provider: 'claude', + workspace_path: '/tmp/workspace', + jsonl_path: null, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + } + : null + )); + const restoreUpdateName = patchMethod(sessionsDb, 'updateSessionCustomName', (sessionId: string, customName: string) => { + updated = { sessionId, customName }; + }); + + try { + llmSessionsService.updateSessionCustomName('known-session', 'New Session Name'); + assert.deepEqual(updated, { + sessionId: 'known-session', + customName: 'New Session Name', + }); + + assert.throws( + () => llmSessionsService.updateSessionCustomName('missing-session', 'Nope'), + (error: unknown) => + error instanceof AppError && + error.code === 'SESSION_NOT_FOUND' && + error.statusCode === 404, + ); + } finally { + restoreUpdateName(); + restoreGetById(); + } +}); + +// This test covers delete behavior using only DB jsonl_path, including invalid id validation. +test('llmSessionsService.deleteSessionArtifacts validates ids and deletes disk/db artifacts', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-delete-session-')); + const transcriptPath = path.join(tempRoot, 'session.jsonl'); + await fs.writeFile(transcriptPath, '{"ok":true}\n', 'utf8'); + + let deletedSessionId: string | null = null; + const restoreGetById = patchMethod(sessionsDb, 'getSessionById', (sessionId: string) => ( + sessionId === 'session-123' + ? { + session_id: 'session-123', + provider: 'cursor', + workspace_path: '/tmp/workspace', + jsonl_path: transcriptPath, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + } + : null + )); + const restoreDelete = patchMethod(sessionsDb, 'deleteSession', (sessionId: string) => { + deletedSessionId = sessionId; + }); + + try { + await assert.rejects( + llmSessionsService.deleteSessionArtifacts('../invalid'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_SESSION_ID' && + error.statusCode === 400, + ); + + const deleted = await llmSessionsService.deleteSessionArtifacts('session-123'); + assert.equal(deleted.sessionId, 'session-123'); + assert.equal(deleted.deletedFromDatabase, true); + assert.equal(deleted.deletedFromDisk, true); + assert.equal(deletedSessionId, 'session-123'); + await assert.rejects(fs.access(transcriptPath)); + + const missing = await llmSessionsService.deleteSessionArtifacts('session-404'); + assert.equal(missing.deletedFromDatabase, false); + assert.equal(missing.deletedFromDisk, false); + } finally { + restoreDelete(); + restoreGetById(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +// This test covers session-history parsing for JSONL (including malformed lines) and Gemini JSON files. +test('llmSessionsService.getSessionHistory parses JSONL and Gemini JSON correctly', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-history-')); + const jsonlPath = path.join(tempRoot, 'session.jsonl'); + const jsonPath = path.join(tempRoot, 'gemini.json'); + await fs.writeFile(jsonlPath, '{"message":"hello"}\nnot-json\n', 'utf8'); + await fs.writeFile(jsonPath, '{"messages":[{"text":"hi"}]}', 'utf8'); + + const restoreGetById = patchMethod(sessionsDb, 'getSessionById', (sessionId: string) => { + if (sessionId === 'jsonl-session') { + return { + session_id: 'jsonl-session', + provider: 'cursor', + workspace_path: '/tmp/workspace', + jsonl_path: jsonlPath, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }; + } + + if (sessionId === 'json-session') { + return { + session_id: 'json-session', + provider: 'gemini', + workspace_path: '/tmp/workspace', + jsonl_path: jsonPath, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }; + } + + if (sessionId === 'missing-history-path') { + return { + session_id: 'missing-history-path', + provider: 'claude', + workspace_path: '/tmp/workspace', + jsonl_path: null, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }; + } + + return null; + }); + + try { + const jsonlHistory = await llmSessionsService.getSessionHistory('jsonl-session'); + assert.equal(jsonlHistory.fileType, 'jsonl'); + assert.equal(Array.isArray(jsonlHistory.entries), true); + assert.equal(jsonlHistory.entries.length, 2); + assert.deepEqual(jsonlHistory.entries[0], { message: 'hello' }); + assert.deepEqual(jsonlHistory.entries[1], { raw: 'not-json', parseError: true }); + + const geminiHistory = await llmSessionsService.getSessionHistory('json-session'); + assert.equal(geminiHistory.fileType, 'json'); + assert.equal(geminiHistory.entries.length, 1); + assert.deepEqual(geminiHistory.entries[0], { messages: [{ text: 'hi' }] }); + + await assert.rejects( + llmSessionsService.getSessionHistory('unknown-session'), + (error: unknown) => + error instanceof AppError && + error.code === 'SESSION_NOT_FOUND' && + error.statusCode === 404, + ); + + await assert.rejects( + llmSessionsService.getSessionHistory('missing-history-path'), + (error: unknown) => + error instanceof AppError && + error.code === 'SESSION_HISTORY_NOT_AVAILABLE' && + error.statusCode === 404, + ); + } finally { + restoreGetById(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +// This test covers conversation search over indexed transcript files with provider/case filters. +test('conversationSearchService searches indexed transcripts with provider and case filters', { concurrency: false }, async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-search-')); + const cursorPath = path.join(tempRoot, 'cursor.jsonl'); + const codexPath = path.join(tempRoot, 'codex.jsonl'); + await fs.writeFile(cursorPath, 'hello world\nNeedle lower\n', 'utf8'); + await fs.writeFile(codexPath, 'HELLO WORLD\nNEEDLE UPPER\n', 'utf8'); + + const restoreGetAll = patchMethod(sessionsDb, 'getAllSessions', () => ([ + { + session_id: 'cursor-s', + provider: 'cursor', + workspace_path: '/tmp/workspace-cursor', + jsonl_path: cursorPath, + custom_name: null, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }, + { + session_id: 'codex-s', + provider: 'codex', + workspace_path: '/tmp/workspace-codex', + jsonl_path: codexPath, + custom_name: null, + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }, + ])); + + try { + await assert.rejects( + conversationSearchService.search({ query: ' ' }), + (error: unknown) => + error instanceof AppError && + error.code === 'SEARCH_QUERY_REQUIRED' && + error.statusCode === 400, + ); + + const anyProviderResults = await conversationSearchService.search({ + query: 'needle', + caseSensitive: false, + limit: 20, + }); + assert.ok(anyProviderResults.some((entry) => entry.sessionId === 'cursor-s')); + assert.ok(anyProviderResults.some((entry) => entry.sessionId === 'codex-s')); + + const codexOnlyResults = await conversationSearchService.search({ + query: 'NEEDLE', + caseSensitive: true, + provider: 'codex', + limit: 20, + }); + assert.ok(codexOnlyResults.length >= 1); + assert.ok(codexOnlyResults.every((entry) => entry.provider === 'codex')); + } finally { + restoreGetAll(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/src/modules/llm/llm.registry.ts b/server/src/modules/llm/llm.registry.ts new file mode 100644 index 00000000..b893aa7e --- /dev/null +++ b/server/src/modules/llm/llm.registry.ts @@ -0,0 +1,42 @@ +import type { IProvider } from '@/modules/llm/providers/provider.interface.js'; +import { ClaudeProvider } from '@/modules/llm/providers/claude.provider.js'; +import { CodexProvider } from '@/modules/llm/providers/codex.provider.js'; +import { CursorProvider } from '@/modules/llm/providers/cursor.provider.js'; +import { GeminiProvider } from '@/modules/llm/providers/gemini.provider.js'; +import type { LLMProvider } from '@/shared/types/app.js'; +import { AppError } from '@/shared/utils/app-error.js'; + +const providers: Record = { + claude: new ClaudeProvider(), + codex: new CodexProvider(), + cursor: new CursorProvider(), + gemini: new GeminiProvider(), +}; + +/** + * Central registry for resolving provider implementations by id. + */ +export const llmProviderRegistry = { + /** + * Returns all registered providers. + */ + listProviders(): IProvider[] { + return Object.values(providers); + }, + + /** + * Resolves one provider or throws a typed 400 error. + */ + resolveProvider(provider: string): IProvider { + const key = provider as LLMProvider; + const resolvedProvider = providers[key]; + if (!resolvedProvider) { + throw new AppError(`Unsupported provider "${provider}".`, { + code: 'UNSUPPORTED_PROVIDER', + statusCode: 400, + }); + } + + return resolvedProvider; + }, +}; diff --git a/server/src/modules/llm/llm.routes.ts b/server/src/modules/llm/llm.routes.ts new file mode 100644 index 00000000..4f229da5 --- /dev/null +++ b/server/src/modules/llm/llm.routes.ts @@ -0,0 +1,295 @@ +import express, { type NextFunction, type Request, type Response } from 'express'; + +import { asyncHandler } from '@/shared/http/async-handler.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js'; +import { llmService } from '@/modules/llm/llm.service.js'; +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import { logger } from '@/shared/utils/logger.js'; + +const router = express.Router(); + +/** + * Safely reads an Express path parameter that may arrive as string or string[]. + */ +const readPathParam = (value: unknown, name: string): string => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0]; + } + + throw new AppError(`${name} path parameter is invalid.`, { + code: 'INVALID_PATH_PARAMETER', + statusCode: 400, + }); +}; + +const normalizeProviderParam = (value: unknown): string => + readPathParam(value, 'provider').trim().toLowerCase(); + +/** + * Allows callers to block until a launched/resumed session reaches a final state. + */ +const parseWaitForCompletion = (req: Request): boolean => { + const value = (req.body as Record | undefined)?.waitForCompletion; + return value === true; +}; + +/** + * Validates and normalizes rename payload. + */ +const parseRenamePayload = (payload: unknown): { summary: string } => { + if (!payload || typeof payload !== 'object') { + throw new AppError('Request body must be an object.', { + code: 'INVALID_REQUEST_BODY', + statusCode: 400, + }); + } + + const body = payload as Record; + const summary = typeof body.summary === 'string' ? body.summary.trim() : ''; + if (!summary) { + throw new AppError('summary is required.', { + code: 'SUMMARY_REQUIRED', + statusCode: 400, + }); + } + + if (summary.length > 500) { + throw new AppError('summary must not exceed 500 characters.', { + code: 'SUMMARY_TOO_LONG', + statusCode: 400, + }); + } + + return { summary }; +}; + +router.get( + '/providers', + asyncHandler(async (_req: Request, res: Response) => { + res.json(createApiSuccessResponse({ providers: llmService.listProviders() })); + }), +); + +router.get( + '/providers/:provider/models', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const models = await llmService.listModels(provider); + res.json(createApiSuccessResponse({ provider, models })); + }), +); + +router.get( + '/providers/:provider/sessions', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessions = llmService.listSessions(provider); + res.json(createApiSuccessResponse({ provider, sessions })); + }), +); + +router.get( + '/providers/:provider/sessions/:sessionId', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const session = llmService.getSession(provider, sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" not found for provider "${provider}".`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + res.json(createApiSuccessResponse({ provider, session })); + }), +); + +router.post( + '/providers/:provider/sessions/start', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const snapshot = await llmService.startSession(provider, req.body); + + const waitForCompletion = parseWaitForCompletion(req); + if (!waitForCompletion) { + res.status(202).json( + createApiSuccessResponse({ + provider, + session: snapshot, + }), + ); + return; + } + + const completedSnapshot = await llmService.waitForSession(provider, snapshot.sessionId); + res.json(createApiSuccessResponse({ provider, session: completedSnapshot ?? snapshot })); + }), +); + +router.post( + '/providers/:provider/sessions/:sessionId/resume', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + + const snapshot = await llmService.resumeSession(provider, sessionId, req.body); + + const waitForCompletion = parseWaitForCompletion(req); + if (!waitForCompletion) { + res.status(202).json(createApiSuccessResponse({ provider, session: snapshot })); + return; + } + + const completedSnapshot = await llmService.waitForSession(provider, sessionId); + res.json(createApiSuccessResponse({ provider, session: completedSnapshot ?? snapshot })); + }), +); + +router.post( + '/providers/:provider/sessions/:sessionId/stop', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const stopped = await llmService.stopSession(provider, sessionId); + res.json(createApiSuccessResponse({ provider, sessionId, stopped })); + }), +); + +router.patch( + '/providers/:provider/sessions/:sessionId/model', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const model = typeof req.body?.model === 'string' ? req.body.model.trim() : ''; + if (!model) { + throw new AppError('model is required.', { + code: 'MODEL_REQUIRED', + statusCode: 400, + }); + } + + await llmService.setSessionModel(provider, sessionId, model); + res.json( + createApiSuccessResponse({ + provider, + sessionId, + model, + }), + ); + }), +); + +router.patch( + '/providers/:provider/sessions/:sessionId/thinking', + asyncHandler(async (req: Request, res: Response) => { + const provider = normalizeProviderParam(req.params.provider); + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const thinkingMode = + typeof req.body?.thinkingMode === 'string' ? req.body.thinkingMode.trim() : ''; + + if (!thinkingMode) { + throw new AppError('thinkingMode is required.', { + code: 'THINKING_MODE_REQUIRED', + statusCode: 400, + }); + } + + await llmService.setSessionThinkingMode(provider, sessionId, thinkingMode); + res.json( + createApiSuccessResponse({ + provider, + sessionId, + thinkingMode, + }), + ); + }), +); + +router.get( + '/sessions/:sessionId/history', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const history = await llmSessionsService.getSessionHistory(sessionId); + res.json(createApiSuccessResponse(history)); + }), +); + +/** + * Renames one indexed session by writing the custom summary into DB. + */ +router.put( + '/sessions/:sessionId/rename', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const { summary } = parseRenamePayload(req.body); + llmSessionsService.updateSessionCustomName(sessionId, summary); + res.json(createApiSuccessResponse({ sessionId, summary })); + }), +); + +/** + * Returns DB-indexed sessions discovered by the session-processor scan. + */ +router.get( + '/sessions/index', + asyncHandler(async (req: Request, res: Response) => { + const provider = + typeof req.query.provider === 'string' ? req.query.provider.trim().toLowerCase() : undefined; + const sessions = llmSessionsService.listIndexedSessions(provider); + res.json(createApiSuccessResponse({ provider: provider ?? null, sessions })); + }), +); + +/** + * Triggers provider disk scans and refreshes the shared sessions table. + */ +router.post( + '/sessions/sync', + asyncHandler(async (_req: Request, res: Response) => { + const syncResult = await llmSessionsService.synchronizeSessions(); + res.json(createApiSuccessResponse(syncResult)); + }), +); + +/** + * Deletes provider-specific session artifacts and removes the DB row. + */ +router.delete( + '/sessions/:sessionId', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = readPathParam(req.params.sessionId, 'sessionId'); + const result = await llmSessionsService.deleteSessionArtifacts(sessionId); + res.json(createApiSuccessResponse(result)); + }), +); + +/** + * Normalizes route-level failures to a consistent JSON API shape. + */ +router.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (res.headersSent) { + return; + } + + if (error instanceof AppError) { + res + .status(error.statusCode) + .json(createApiErrorResponse(error.code, error.message, undefined, error.details)); + return; + } + + const message = error instanceof Error ? error.message : 'Unexpected LLM route failure.'; + logger.error(message, { + module: 'llm.routes', + }); + + res.status(500).json(createApiErrorResponse('INTERNAL_ERROR', message)); +}); + +export default router; diff --git a/server/src/modules/llm/llm.service.ts b/server/src/modules/llm/llm.service.ts new file mode 100644 index 00000000..fe4a0632 --- /dev/null +++ b/server/src/modules/llm/llm.service.ts @@ -0,0 +1,180 @@ +import type { LLMProvider } from '@/shared/types/app.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import { llmProviderRegistry } from '@/modules/llm/llm.registry.js'; +import type { + ProviderModel, + ProviderSessionSnapshot, + RuntimePermissionMode, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; + +/** + * Converts unknown request values into optional trimmed strings. + */ +const normalizeOptionalString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +}; + +/** + * Validates and normalizes runtime permission mode. + */ +const normalizePermissionMode = (value: unknown): RuntimePermissionMode | undefined => { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + + if (normalized === 'ask' || normalized === 'allow' || normalized === 'deny') { + return normalized; + } + + throw new AppError(`Unsupported runtimePermissionMode "${normalized}".`, { + code: 'INVALID_RUNTIME_PERMISSION_MODE', + statusCode: 400, + }); +}; + +/** + * Facade over provider implementations with payload validation and capability checks. + */ +export const llmService = { + listProviders(): Array<{ + id: LLMProvider; + family: 'sdk' | 'cli'; + capabilities: { + supportsRuntimePermissionRequests: boolean; + supportsThinkingModeControl: boolean; + supportsModelSwitching: boolean; + supportsSessionResume: boolean; + supportsSessionStop: boolean; + }; + }> { + return llmProviderRegistry.listProviders().map((provider) => ({ + id: provider.id, + family: provider.family, + capabilities: provider.capabilities, + })); + }, + + async listModels(providerName: string): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.listModels(); + }, + + listSessions(providerName: string): ProviderSessionSnapshot[] { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.listSessions(); + }, + + getSession(providerName: string, sessionId: string): ProviderSessionSnapshot | null { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.getSession(sessionId); + }, + + async startSession(providerName: string, payload: unknown): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + const input = parseStartPayload(payload); + validateCapabilityContracts(provider.capabilities, input); + return provider.launchSession(input); + }, + + async resumeSession( + providerName: string, + sessionId: string, + payload: unknown, + ): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + const input = parseStartPayload(payload); + validateCapabilityContracts(provider.capabilities, input); + return provider.resumeSession({ ...input, sessionId }); + }, + + async waitForSession(providerName: string, sessionId: string): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.waitForSession(sessionId); + }, + + async stopSession(providerName: string, sessionId: string): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.stopSession(sessionId); + }, + + async setSessionModel(providerName: string, sessionId: string, model: string): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + await provider.setSessionModel(sessionId, model); + }, + + async setSessionThinkingMode( + providerName: string, + sessionId: string, + thinkingMode: string, + ): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + await provider.setSessionThinkingMode(sessionId, thinkingMode); + }, +}; + +/** + * Parses and validates session start/resume request payloads. + */ +function parseStartPayload(payload: unknown): StartSessionInput { + if (!payload || typeof payload !== 'object') { + throw new AppError('Request body must be an object.', { + code: 'INVALID_REQUEST_BODY', + statusCode: 400, + }); + } + + const body = payload as Record; + const prompt = normalizeOptionalString(body.prompt); + if (!prompt) { + throw new AppError('prompt is required.', { + code: 'PROMPT_REQUIRED', + statusCode: 400, + }); + } + + return { + prompt, + workspacePath: normalizeOptionalString(body.workspacePath), + sessionId: normalizeOptionalString(body.sessionId), + model: normalizeOptionalString(body.model), + thinkingMode: normalizeOptionalString(body.thinkingMode), + runtimePermissionMode: normalizePermissionMode(body.runtimePermissionMode), + allowYolo: body.allowYolo === true, + }; +} + +/** + * Enforces capability contracts before provider invocation. + */ +function validateCapabilityContracts( + capabilities: { + supportsRuntimePermissionRequests: boolean; + supportsThinkingModeControl: boolean; + }, + input: StartSessionInput, +): void { + if ( + input.runtimePermissionMode && + input.runtimePermissionMode !== 'ask' && + !capabilities.supportsRuntimePermissionRequests + ) { + throw new AppError('Runtime permission requests are not supported by this provider.', { + code: 'RUNTIME_PERMISSION_NOT_SUPPORTED', + statusCode: 400, + }); + } + + if (input.thinkingMode && !capabilities.supportsThinkingModeControl) { + throw new AppError('Thinking mode is not supported by this provider.', { + code: 'THINKING_MODE_NOT_SUPPORTED', + statusCode: 400, + }); + } +} diff --git a/server/src/modules/llm/providers/abstract.provider.ts b/server/src/modules/llm/providers/abstract.provider.ts new file mode 100644 index 00000000..5ed6d171 --- /dev/null +++ b/server/src/modules/llm/providers/abstract.provider.ts @@ -0,0 +1,267 @@ +import { AppError } from '@/shared/utils/app-error.js'; +import type { + IProvider, + MutableProviderSession, + ProviderCapabilities, + ProviderExecutionFamily, + ProviderModel, + ProviderSessionEvent, + ProviderSessionSnapshot, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import type { LLMProvider } from '@/shared/types/app.js'; + +type SessionPreference = { + model?: string; + thinkingMode?: string; +}; + +const MAX_EVENT_BUFFER_SIZE = 2_000; + +/** + * Shared provider base for session lifecycle state and capability gating. + */ +export abstract class AbstractProvider implements IProvider { + readonly id: LLMProvider; + readonly family: ProviderExecutionFamily; + readonly capabilities: ProviderCapabilities; + + protected readonly sessions = new Map(); + protected readonly sessionPreferences = new Map(); + + protected constructor( + id: LLMProvider, + family: ProviderExecutionFamily, + capabilities: ProviderCapabilities, + ) { + this.id = id; + this.family = family; + this.capabilities = capabilities; + } + + abstract listModels(): Promise; + abstract launchSession(input: StartSessionInput): Promise; + abstract resumeSession( + input: StartSessionInput & { sessionId: string }, + ): Promise; + + /** + * Returns one in-memory session snapshot when present. + */ + getSession(sessionId: string): ProviderSessionSnapshot | null { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + return this.toSnapshot(session); + } + + /** + * Returns snapshots of all in-memory sessions. + */ + listSessions(): ProviderSessionSnapshot[] { + return [...this.sessions.values()].map((session) => this.toSnapshot(session)); + } + + /** + * Waits for a running session to complete and returns the final snapshot. + */ + async waitForSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + await session.completion; + return this.toSnapshot(session); + } + + /** + * Requests a graceful session stop. + */ + async stopSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + + const stopped = await session.stop(); + if (stopped && session.status === 'running') { + this.updateSessionStatus(session, 'stopped'); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: 'Session stop requested.', + }); + } + + return stopped; + } + + /** + * Validates/supports model switching and updates both live and persisted state. + */ + async setSessionModel(sessionId: string, model: string): Promise { + if (!this.capabilities.supportsModelSwitching) { + throw new AppError(`Provider "${this.id}" does not support model switching.`, { + code: 'MODEL_SWITCH_NOT_SUPPORTED', + statusCode: 400, + }); + } + + const trimmedModel = model.trim(); + if (!trimmedModel) { + throw new AppError('Model cannot be empty.', { + code: 'INVALID_MODEL', + statusCode: 400, + }); + } + + const session = this.sessions.get(sessionId); + if (session?.setModel) { + await session.setModel(trimmedModel); + } + + const currentPreference = this.sessionPreferences.get(sessionId) ?? {}; + this.sessionPreferences.set(sessionId, { ...currentPreference, model: trimmedModel }); + + if (session) { + session.model = trimmedModel; + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: `Model updated to "${trimmedModel}".`, + }); + } + } + + /** + * Validates/supports thinking mode updates and applies them to live/persisted state. + */ + async setSessionThinkingMode(sessionId: string, thinkingMode: string): Promise { + if (!this.capabilities.supportsThinkingModeControl) { + throw new AppError(`Provider "${this.id}" does not support thinking mode control.`, { + code: 'THINKING_MODE_NOT_SUPPORTED', + statusCode: 400, + }); + } + + const trimmedMode = thinkingMode.trim(); + if (!trimmedMode) { + throw new AppError('Thinking mode cannot be empty.', { + code: 'INVALID_THINKING_MODE', + statusCode: 400, + }); + } + + const session = this.sessions.get(sessionId); + if (session?.setThinkingMode) { + await session.setThinkingMode(trimmedMode); + } + + const currentPreference = this.sessionPreferences.get(sessionId) ?? {}; + this.sessionPreferences.set(sessionId, { ...currentPreference, thinkingMode: trimmedMode }); + + if (session) { + session.thinkingMode = trimmedMode; + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: `Thinking mode updated to "${trimmedMode}".`, + }); + } + } + + /** + * Reads saved preferences for resumed sessions. + */ + protected getSessionPreference(sessionId: string): SessionPreference { + return this.sessionPreferences.get(sessionId) ?? {}; + } + + /** + * Stores session preferences for subsequent resume/start operations. + */ + protected rememberSessionPreference(sessionId: string, preference: SessionPreference): void { + const currentPreference = this.sessionPreferences.get(sessionId) ?? {}; + this.sessionPreferences.set(sessionId, { + ...currentPreference, + ...preference, + }); + } + + /** + * Creates mutable internal session state and registers it in memory. + */ + protected createSessionRecord( + sessionId: string, + input: { + model?: string; + thinkingMode?: string; + }, + ): MutableProviderSession { + const session: MutableProviderSession = { + sessionId, + provider: this.id, + family: this.family, + status: 'running', + startedAt: new Date().toISOString(), + model: input.model, + thinkingMode: input.thinkingMode, + events: [], + completion: Promise.resolve(), + stop: async () => false, + }; + + this.sessions.set(sessionId, session); + this.rememberSessionPreference(sessionId, { + model: input.model, + thinkingMode: input.thinkingMode, + }); + + return session; + } + + /** + * Appends an event while enforcing the configured ring-buffer size. + */ + protected appendEvent(session: MutableProviderSession, event: ProviderSessionEvent): void { + session.events.push(event); + + if (session.events.length > MAX_EVENT_BUFFER_SIZE) { + session.events.splice(0, session.events.length - MAX_EVENT_BUFFER_SIZE); + } + } + + /** + * Marks the terminal state for a session. + */ + protected updateSessionStatus( + session: MutableProviderSession, + status: MutableProviderSession['status'], + error?: string, + ): void { + session.status = status; + session.endedAt = new Date().toISOString(); + session.error = error; + } + + /** + * Converts mutable internal session state to an external snapshot. + */ + protected toSnapshot(session: MutableProviderSession): ProviderSessionSnapshot { + return { + sessionId: session.sessionId, + provider: session.provider, + family: session.family, + status: session.status, + startedAt: session.startedAt, + endedAt: session.endedAt, + model: session.model, + thinkingMode: session.thinkingMode, + events: [...session.events], + error: session.error, + }; + } +} diff --git a/server/src/modules/llm/providers/base-cli.provider.ts b/server/src/modules/llm/providers/base-cli.provider.ts new file mode 100644 index 00000000..7129a4ec --- /dev/null +++ b/server/src/modules/llm/providers/base-cli.provider.ts @@ -0,0 +1,284 @@ +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +import spawn from 'cross-spawn'; + +import { AbstractProvider } from '@/modules/llm/providers/abstract.provider.js'; +import type { + MutableProviderSession, + ProviderCapabilities, + ProviderSessionEvent, + ProviderSessionSnapshot, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import { createStreamLineAccumulator } from '@/shared/platform/stream.js'; +import type { LLMProvider } from '@/shared/types/app.js'; + +type CreateCliInvocationInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +type CliInvocation = { + command: string; + args: string[]; + cwd?: string; + env?: Record; +}; + +const PROCESS_SHUTDOWN_GRACE_PERIOD_MS = 2_000; + +/** + * Base class for CLI-driven providers with streamed stdout/stderr parsing. + */ +export abstract class BaseCliProvider extends AbstractProvider { + protected constructor(providerId: LLMProvider, capabilities: ProviderCapabilities) { + super(providerId, 'cli', capabilities); + } + + /** + * Starts a new CLI session and begins process output streaming. + */ + async launchSession(input: StartSessionInput): Promise { + return this.startSessionInternal({ + ...input, + sessionId: input.sessionId ?? randomUUID(), + isResume: false, + }); + } + + /** + * Resumes an existing CLI session and begins process output streaming. + */ + async resumeSession(input: StartSessionInput & { sessionId: string }): Promise { + return this.startSessionInternal({ + ...input, + isResume: true, + }); + } + + /** + * Implemented by concrete CLI providers to describe command invocation. + */ + protected abstract createCliInvocation(input: CreateCliInvocationInput): CliInvocation; + + /** + * Maps one stdout/stderr line into either JSON or plain-text event shapes. + */ + protected mapCliOutputLine(line: string, channel: 'stdout' | 'stderr'): ProviderSessionEvent { + const parsedJson = this.tryParseJson(line); + if (parsedJson !== null) { + return { + timestamp: new Date().toISOString(), + channel: 'json', + data: parsedJson, + }; + } + + return { + timestamp: new Date().toISOString(), + channel, + message: line, + }; + } + + /** + * Runs a one-off CLI command and returns full stdout text on success. + */ + protected async runCommandForOutput(command: string, args: string[]): Promise { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: process.cwd(), + env: process.env, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const closePromise = once(child, 'close'); + const errorPromise = once(child, 'error').then(([error]) => { + throw error; + }); + + await Promise.race([closePromise, errorPromise]); + + if ((child.exitCode ?? 1) !== 0) { + const message = stderr.trim() || `Command "${command}" failed with code ${child.exitCode}`; + throw new Error(message); + } + + return stdout; + } + + /** + * Boots one CLI child process and wires stream handlers to the session buffer. + */ + private async startSessionInternal(input: CreateCliInvocationInput): Promise { + const preferred = this.getSessionPreference(input.sessionId); + const effectiveModel = input.model ?? preferred.model; + const effectiveThinking = input.thinkingMode ?? preferred.thinkingMode; + + const session = this.createSessionRecord(input.sessionId, { + model: effectiveModel, + thinkingMode: effectiveThinking, + }); + + const invocation = this.createCliInvocation({ + ...input, + model: effectiveModel, + thinkingMode: effectiveThinking, + }); + + const child = spawn(invocation.command, invocation.args, { + cwd: invocation.cwd ?? input.workspacePath ?? process.cwd(), + env: { + ...process.env, + ...invocation.env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams; + + const stop = async (): Promise => this.terminateChildProcess(child); + session.stop = stop; + + const stdoutAccumulator = createStreamLineAccumulator({ preserveEmptyLines: false }); + const stderrAccumulator = createStreamLineAccumulator({ preserveEmptyLines: false }); + + child.stdout.on('data', (chunk) => { + const lines = stdoutAccumulator.push(chunk); + for (const line of lines) { + const event = this.mapCliOutputLine(line, 'stdout'); + this.appendEvent(session, event); + } + }); + + child.stderr.on('data', (chunk) => { + const lines = stderrAccumulator.push(chunk); + for (const line of lines) { + const event = this.mapCliOutputLine(line, 'stderr'); + this.appendEvent(session, event); + } + }); + + session.completion = this.waitForCliProcess( + session, + child, + stdoutAccumulator, + stderrAccumulator, + ); + return this.toSnapshot(session); + } + + /** + * Waits for process completion/error and marks final session status. + */ + private async waitForCliProcess( + session: MutableProviderSession, + child: ChildProcessWithoutNullStreams, + stdoutAccumulator: { flush: () => string[] }, + stderrAccumulator: { flush: () => string[] }, + ): Promise { + const closePromise = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>; + const errorPromise = once(child, 'error') as Promise<[Error]>; + const raceResult = await Promise.race([ + closePromise.then((result) => ({ type: 'close' as const, result })), + errorPromise.then((result) => ({ type: 'error' as const, result })), + ]); + + const pendingStdout = stdoutAccumulator.flush(); + const pendingStderr = stderrAccumulator.flush(); + + for (const line of pendingStdout) { + this.appendEvent(session, this.mapCliOutputLine(line, 'stdout')); + } + + for (const line of pendingStderr) { + this.appendEvent(session, this.mapCliOutputLine(line, 'stderr')); + } + + if (raceResult.type === 'error') { + const [error] = raceResult.result; + const message = error.message || 'CLI process failed before start.'; + this.updateSessionStatus(session, 'failed', message); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'error', + message, + }); + return; + } + + const [code, signal] = raceResult.result; + + if (session.status === 'stopped') { + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: `Session stopped (${signal ?? 'SIGTERM'}).`, + }); + return; + } + + if (code === 0) { + this.updateSessionStatus(session, 'completed'); + return; + } + + const message = `CLI command exited with code ${code ?? 'null'}${signal ? ` (signal: ${signal})` : ''}`; + this.updateSessionStatus(session, 'failed', message); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'error', + message, + }); + } + + /** + * Attempts graceful termination first, then force-kills when necessary. + */ + private async terminateChildProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.killed || child.exitCode !== null) { + return true; + } + + try { + child.kill('SIGTERM'); + await Promise.race([ + once(child, 'close'), + new Promise((resolve) => setTimeout(resolve, PROCESS_SHUTDOWN_GRACE_PERIOD_MS)), + ]); + + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + + return true; + } catch { + return false; + } + } + + /** + * Best-effort JSON parser for stream-json providers. + */ + private tryParseJson(line: string): unknown | null { + const trimmed = line.trim(); + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } + } +} diff --git a/server/src/modules/llm/providers/base-sdk.provider.ts b/server/src/modules/llm/providers/base-sdk.provider.ts new file mode 100644 index 00000000..18a87097 --- /dev/null +++ b/server/src/modules/llm/providers/base-sdk.provider.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto'; + +import { AbstractProvider } from '@/modules/llm/providers/abstract.provider.js'; +import type { + MutableProviderSession, + ProviderCapabilities, + ProviderSessionEvent, + ProviderSessionSnapshot, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; +import type { LLMProvider } from '@/shared/types/app.js'; + +type CreateSdkExecutionInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +type SdkExecution = { + stream: AsyncIterable; + stop: () => Promise; + setModel?: (model: string) => Promise; + setThinkingMode?: (thinkingMode: string) => Promise; +}; + +/** + * Base class for SDK-driven providers with async stream consumption. + */ +export abstract class BaseSdkProvider extends AbstractProvider { + protected constructor(providerId: LLMProvider, capabilities: ProviderCapabilities) { + super(providerId, 'sdk', capabilities); + } + + /** + * Starts a new SDK session and begins event streaming. + */ + async launchSession(input: StartSessionInput): Promise { + return this.startSessionInternal({ + ...input, + sessionId: input.sessionId ?? randomUUID(), + isResume: false, + }); + } + + /** + * Resumes an existing SDK session and begins event streaming. + */ + async resumeSession(input: StartSessionInput & { sessionId: string }): Promise { + return this.startSessionInternal({ + ...input, + isResume: true, + }); + } + + /** + * Implemented by concrete SDK providers to create a running execution. + */ + protected abstract createSdkExecution(input: CreateSdkExecutionInput): Promise; + + /** + * Normalizes raw SDK events to the shared event shape. + */ + protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null { + return { + timestamp: new Date().toISOString(), + channel: 'sdk', + data: rawEvent, + }; + } + + /** + * Initializes one SDK execution and wires it to the internal session record. + */ + private async startSessionInternal(input: CreateSdkExecutionInput): Promise { + const preferred = this.getSessionPreference(input.sessionId); + const effectiveModel = input.model ?? preferred.model; + const effectiveThinking = input.thinkingMode ?? preferred.thinkingMode; + + const session = this.createSessionRecord(input.sessionId, { + model: effectiveModel, + thinkingMode: effectiveThinking, + }); + + let execution: SdkExecution; + try { + execution = await this.createSdkExecution({ + ...input, + model: effectiveModel, + thinkingMode: effectiveThinking, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start SDK session'; + this.updateSessionStatus(session, 'failed', message); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'error', + message, + }); + throw error; + } + + session.stop = execution.stop; + session.setModel = execution.setModel; + session.setThinkingMode = execution.setThinkingMode; + + session.completion = this.consumeStream(session, execution.stream); + return this.toSnapshot(session); + } + + /** + * Drains SDK events until completion/error and updates final status. + */ + private async consumeStream( + session: MutableProviderSession, + stream: AsyncIterable, + ): Promise { + try { + for await (const sdkEvent of stream) { + const normalized = this.mapSdkEvent(sdkEvent); + if (normalized) { + this.appendEvent(session, normalized); + } + } + + if (session.status === 'running') { + this.updateSessionStatus(session, 'completed'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown SDK execution failure'; + + if (session.status === 'stopped') { + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'system', + message: 'Session stopped.', + }); + return; + } + + this.updateSessionStatus(session, 'failed', message); + this.appendEvent(session, { + timestamp: new Date().toISOString(), + channel: 'error', + message, + }); + } + } +} diff --git a/server/src/modules/llm/providers/claude.provider.ts b/server/src/modules/llm/providers/claude.provider.ts new file mode 100644 index 00000000..23457ea4 --- /dev/null +++ b/server/src/modules/llm/providers/claude.provider.ts @@ -0,0 +1,182 @@ +import { + query, + type CanUseTool, + type ModelInfo, + type Options, +} from '@anthropic-ai/claude-agent-sdk'; + +import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js'; +import type { + ProviderModel, + ProviderSessionEvent, + RuntimePermissionMode, + StartSessionInput, +} from '@/modules/llm/providers/provider.interface.js'; + +type ClaudeExecutionInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +const CLAUDE_THINKING_LEVELS = new Set(['low', 'medium', 'high', 'max']); + +/** + * Claude SDK provider implementation. + */ +export class ClaudeProvider extends BaseSdkProvider { + constructor() { + super('claude', { + supportsRuntimePermissionRequests: true, + supportsThinkingModeControl: true, + supportsModelSwitching: true, + supportsSessionResume: true, + supportsSessionStop: true, + }); + } + + /** + * Retrieves available Claude models from the SDK. + */ + async listModels(): Promise { + const probe = query({ + prompt: 'model_probe', + options: { + permissionMode: 'plan', + }, + }); + + try { + const models = await probe.supportedModels(); + return models.map((model) => this.mapModelInfo(model)); + } finally { + probe.close(); + } + } + + /** + * Creates a Claude SDK query execution for start/resume flows. + */ + protected async createSdkExecution(input: ClaudeExecutionInput): Promise<{ + stream: AsyncIterable; + stop: () => Promise; + setModel: (model: string) => Promise; + }> { + const options: Options = { + cwd: input.workspacePath, + model: input.model, + effort: this.resolveClaudeEffort(input.thinkingMode), + canUseTool: this.resolvePermissionHandler(input.runtimePermissionMode), + }; + + if (input.isResume) { + options.resume = input.sessionId; + } else { + options.sessionId = input.sessionId; + } + + const queryInstance = query({ + prompt: input.prompt, + options, + }); + + return { + stream: queryInstance, + stop: async () => { + await queryInstance.interrupt(); + return true; + }, + setModel: async (model: string) => { + await queryInstance.setModel(model); + }, + }; + } + + /** + * Produces compact event metadata for frontend stream rendering. + */ + protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null { + if (typeof rawEvent !== 'object' || rawEvent === null) { + return { + timestamp: new Date().toISOString(), + channel: 'sdk', + message: String(rawEvent), + }; + } + + const messageType = this.getStringProperty(rawEvent, 'type'); + const messageSubtype = this.getStringProperty(rawEvent, 'subtype'); + const message = [messageType, messageSubtype].filter(Boolean).join(':') || 'claude_event'; + + return { + timestamp: new Date().toISOString(), + channel: 'sdk', + message, + data: rawEvent, + }; + } + + /** + * Normalizes Claude model metadata to the shared model shape. + */ + private mapModelInfo(model: ModelInfo): ProviderModel { + return { + value: model.value, + displayName: model.displayName, + description: model.description, + supportsThinkingModes: Boolean(model.supportsEffort), + supportedThinkingModes: model.supportedEffortLevels, + }; + } + + /** + * Maps requested thinking mode to Claude effort levels. + */ + private resolveClaudeEffort(thinkingMode?: string): Options['effort'] { + if (!thinkingMode) { + return 'high'; + } + + const normalized = thinkingMode.trim().toLowerCase(); + if (CLAUDE_THINKING_LEVELS.has(normalized)) { + return normalized as Options['effort']; + } + + return 'high'; + } + + /** + * Builds a runtime permission callback when explicit allow/deny is requested. + */ + private resolvePermissionHandler(mode?: RuntimePermissionMode): CanUseTool | undefined { + if (!mode || mode === 'ask') { + return undefined; + } + + if (mode === 'allow') { + return async () => ({ behavior: 'allow' }); + } + + return async () => ({ + behavior: 'deny', + message: 'Permission denied by runtime permission mode.', + interrupt: false, + }); + } + + /** + * Reads one optional string property from an unknown event object. + */ + private getStringProperty(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const rawValue = record[key]; + if (typeof rawValue !== 'string') { + return undefined; + } + + return rawValue; + } +} diff --git a/server/src/modules/llm/providers/codex.provider.ts b/server/src/modules/llm/providers/codex.provider.ts new file mode 100644 index 00000000..19e577d1 --- /dev/null +++ b/server/src/modules/llm/providers/codex.provider.ts @@ -0,0 +1,171 @@ +import os from 'node:os'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js'; +import type { ProviderModel, ProviderSessionEvent, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; +import { AppError } from '@/shared/utils/app-error.js'; + +type CodexExecutionInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +type CodexModelCacheEntry = { + slug?: string; + display_name?: string; + description?: string; + supported_reasoning_levels?: Array<{ + effort?: string; + description?: string; + }>; + priority?: number; +}; + +type CodexSdkClient = { + startThread: (options?: Record) => CodexThread; + resumeThread: (sessionId: string, options?: Record) => CodexThread; +}; + +type CodexThread = { + runStreamed: ( + prompt: string, + options?: { + signal?: AbortSignal; + }, + ) => Promise<{ + events: AsyncIterable; + }>; +}; + +type CodexSdkModule = { + Codex: new () => CodexSdkClient; +}; + +/** + * Codex SDK provider implementation. + */ +export class CodexProvider extends BaseSdkProvider { + constructor() { + super('codex', { + supportsRuntimePermissionRequests: false, + supportsThinkingModeControl: true, + supportsModelSwitching: true, + supportsSessionResume: true, + supportsSessionStop: true, + }); + } + + /** + * Reads codex models from ~/.codex/models_cache.json. + */ + async listModels(): Promise { + const modelCachePath = path.join(os.homedir(), '.codex', 'models_cache.json'); + let content: string; + try { + content = await readFile(modelCachePath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + throw new AppError('Codex model cache was not found. Expected ~/.codex/models_cache.json.', { + code: 'CODEX_MODEL_CACHE_NOT_FOUND', + statusCode: 404, + }); + } + + throw error; + } + + const parsed = JSON.parse(content) as { models?: CodexModelCacheEntry[] }; + + const models = parsed.models ?? []; + return models + .filter((entry) => Boolean(entry.slug)) + .map((entry) => ({ + value: entry.slug as string, + displayName: entry.display_name ?? entry.slug ?? 'unknown', + description: entry.description, + default: entry.priority === 1, + supportsThinkingModes: Boolean(entry.supported_reasoning_levels?.length), + supportedThinkingModes: entry.supported_reasoning_levels + ?.map((level) => level.effort) + .filter((effort): effort is string => typeof effort === 'string'), + })); + } + + /** + * Creates a Codex thread execution and wires abort support. + */ + protected async createSdkExecution(input: CodexExecutionInput): Promise<{ + stream: AsyncIterable; + stop: () => Promise; + }> { + const sdkModule = await this.loadCodexSdkModule(); + const client = new sdkModule.Codex(); + + const threadOptions: Record = { + model: input.model, + workingDirectory: input.workspacePath, + modelReasoningEffort: input.thinkingMode, + }; + + const thread = input.isResume + ? client.resumeThread(input.sessionId, threadOptions) + : client.startThread(threadOptions); + + const abortController = new AbortController(); + const streamedTurn = await thread.runStreamed(input.prompt, { + signal: abortController.signal, + }); + + return { + stream: streamedTurn.events, + stop: async () => { + abortController.abort('Session stop requested'); + return true; + }, + }; + } + + /** + * Normalizes Codex stream events into the shared event shape. + */ + protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null { + if (typeof rawEvent !== 'object' || rawEvent === null) { + return { + timestamp: new Date().toISOString(), + channel: 'sdk', + message: String(rawEvent), + }; + } + + const record = rawEvent as Record; + const message = typeof record.type === 'string' ? record.type : 'codex_event'; + + return { + timestamp: new Date().toISOString(), + channel: 'sdk', + message, + data: rawEvent, + }; + } + + /** + * Dynamically imports the Codex SDK to support environments where it is optional. + */ + private async loadCodexSdkModule(): Promise { + try { + const sdkModule = (await import('@openai/codex-sdk')) as unknown as CodexSdkModule; + if (!sdkModule?.Codex) { + throw new Error('Codex SDK did not export "Codex".'); + } + return sdkModule; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to import Codex SDK'; + throw new AppError(`Codex SDK is unavailable: ${message}`, { + code: 'CODEX_SDK_UNAVAILABLE', + statusCode: 503, + }); + } + } +} diff --git a/server/src/modules/llm/providers/cursor.provider.ts b/server/src/modules/llm/providers/cursor.provider.ts new file mode 100644 index 00000000..a19da697 --- /dev/null +++ b/server/src/modules/llm/providers/cursor.provider.ts @@ -0,0 +1,123 @@ +import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js'; +import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; + +type CursorExecutionInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +const ANSI_REGEX = + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping. + /\u001b\[[0-9;]*m/g; + +/** + * Cursor CLI provider implementation. + */ +export class CursorProvider extends BaseCliProvider { + constructor() { + super('cursor', { + supportsRuntimePermissionRequests: false, + supportsThinkingModeControl: false, + supportsModelSwitching: true, + supportsSessionResume: true, + supportsSessionStop: true, + }); + } + + /** + * Lists cursor models by parsing `cursor-agent --list-models`. + */ + async listModels(): Promise { + const output = await this.runCommandForOutput('cursor-agent', ['--list-models']); + return this.parseModelsOutput(output); + } + + /** + * Creates the command invocation for cursor start/resume flows. + */ + protected createCliInvocation(input: CursorExecutionInput): { + command: string; + args: string[]; + cwd?: string; + } { + const args = ['--print', '--trust', '--output-format', 'stream-json']; + + if (input.allowYolo) { + args.push('--yolo'); + } + + if (input.model) { + args.push('--model', input.model); + } + + if (input.isResume) { + args.push('--resume', input.sessionId); + } + + args.push(input.prompt); + + return { + command: 'cursor-agent', + args, + cwd: input.workspacePath, + }; + } + + /** + * Parses full model-list output into normalized model entries. + */ + private parseModelsOutput(output: string): ProviderModel[] { + const models: ProviderModel[] = []; + const lines = output.replace(ANSI_REGEX, '').split(/\r?\n/); + + for (const line of lines) { + const parsed = this.parseModelLine(line); + if (!parsed) { + continue; + } + models.push(parsed); + } + + return models; + } + + /** + * Parses one cursor model line. + */ + private parseModelLine(line: string): ProviderModel | null { + const trimmed = line.trim(); + if ( + !trimmed || + trimmed === 'Available models' || + trimmed.startsWith('Loading models') || + trimmed.startsWith('Tip:') + ) { + return null; + } + + const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/); + if (!match) { + return null; + } + + const value = match[1].trim(); + const descriptionRaw = match[2].trim(); + + const current = /\(current\)/i.test(descriptionRaw); + const defaultModel = /\(default\)/i.test(descriptionRaw); + const description = descriptionRaw + .replace(/\s*\((current|default)\)/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); + + return { + value, + displayName: value, + description, + current, + default: defaultModel, + supportsThinkingModes: false, + supportedThinkingModes: [], + }; + } +} diff --git a/server/src/modules/llm/providers/gemini.provider.ts b/server/src/modules/llm/providers/gemini.provider.ts new file mode 100644 index 00000000..f01b1036 --- /dev/null +++ b/server/src/modules/llm/providers/gemini.provider.ts @@ -0,0 +1,66 @@ +import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js'; +import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js'; + +type GeminiExecutionInput = StartSessionInput & { + sessionId: string; + isResume: boolean; +}; + +const GEMINI_MODELS: ProviderModel[] = [ + { value: 'gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' }, + { value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' }, + { value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.0-flash-lite', displayName: 'Gemini 2.0 Flash Lite' }, + { value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' }, + { value: 'gemini-2.0-pro-exp', displayName: 'Gemini 2.0 Pro Experimental' }, + { value: 'gemini-2.0-flash-thinking-exp', displayName: 'Gemini 2.0 Flash Thinking' }, +]; + +/** + * Gemini CLI provider implementation. + */ +export class GeminiProvider extends BaseCliProvider { + constructor() { + super('gemini', { + supportsRuntimePermissionRequests: false, + supportsThinkingModeControl: false, + supportsModelSwitching: true, + supportsSessionResume: true, + supportsSessionStop: true, + }); + } + + /** + * Returns curated Gemini model options from the refactor doc. + */ + async listModels(): Promise { + return GEMINI_MODELS; + } + + /** + * Creates the command invocation for gemini start/resume flows. + */ + protected createCliInvocation(input: GeminiExecutionInput): { + command: string; + args: string[]; + cwd?: string; + } { + const args = ['--prompt', input.prompt, '--output-format', 'stream-json']; + + if (input.model) { + args.push('--model', input.model); + } + + if (input.isResume) { + args.push('--resume', input.sessionId); + } + + return { + command: 'gemini', + args, + cwd: input.workspacePath, + }; + } +} diff --git a/server/src/modules/llm/providers/provider.interface.ts b/server/src/modules/llm/providers/provider.interface.ts new file mode 100644 index 00000000..70da652c --- /dev/null +++ b/server/src/modules/llm/providers/provider.interface.ts @@ -0,0 +1,103 @@ +import type { LLMProvider } from '@/shared/types/app.js'; + +export type ProviderExecutionFamily = 'sdk' | 'cli'; + +export type ProviderSessionStatus = 'running' | 'completed' | 'failed' | 'stopped'; + +export type RuntimePermissionMode = 'ask' | 'allow' | 'deny'; + +/** + * Advertises optional provider behaviors so route/service code can gate features. + */ +export type ProviderCapabilities = { + supportsRuntimePermissionRequests: boolean; + supportsThinkingModeControl: boolean; + supportsModelSwitching: boolean; + supportsSessionResume: boolean; + supportsSessionStop: boolean; +}; + +/** + * Provider model descriptor normalized for frontend consumption. + */ +export type ProviderModel = { + value: string; + displayName: string; + description?: string; + default?: boolean; + current?: boolean; + supportsThinkingModes?: boolean; + supportedThinkingModes?: string[]; +}; + +/** + * Unified in-memory event emitted while a provider session runs. + */ +export type ProviderSessionEvent = { + timestamp: string; + channel: 'sdk' | 'stdout' | 'stderr' | 'json' | 'system' | 'error'; + message?: string; + data?: unknown; +}; + +/** + * Common launch/resume payload consumed by all providers. + */ +export type StartSessionInput = { + prompt: string; + workspacePath?: string; + sessionId?: string; + model?: string; + thinkingMode?: string; + runtimePermissionMode?: RuntimePermissionMode; + allowYolo?: boolean; +}; + +/** + * Snapshot shape exposed externally for a provider session. + */ +export type ProviderSessionSnapshot = { + sessionId: string; + provider: LLMProvider; + family: ProviderExecutionFamily; + status: ProviderSessionStatus; + startedAt: string; + endedAt?: string; + model?: string; + thinkingMode?: string; + events: ProviderSessionEvent[]; + error?: string; +}; + +/** + * Provider contract that both SDK and CLI families implement. + */ +export interface IProvider { + readonly id: LLMProvider; + readonly family: ProviderExecutionFamily; + readonly capabilities: ProviderCapabilities; + + listModels(): Promise; + + launchSession(input: StartSessionInput): Promise; + resumeSession(input: StartSessionInput & { sessionId: string }): Promise; + + stopSession(sessionId: string): Promise; + setSessionModel(sessionId: string, model: string): Promise; + setSessionThinkingMode(sessionId: string, thinkingMode: string): Promise; + + getSession(sessionId: string): ProviderSessionSnapshot | null; + listSessions(): ProviderSessionSnapshot[]; + waitForSession(sessionId: string): Promise; +} + +/** + * Internal mutable session state used by provider base classes. + */ +export type MutableProviderSession = Omit & { + events: ProviderSessionEvent[]; + completion: Promise; + stop: () => Promise; + setModel?: (model: string) => Promise; + setThinkingMode?: (thinkingMode: string) => Promise; +}; diff --git a/server/src/modules/llm/session-indexers/claude.session-indexer.ts b/server/src/modules/llm/session-indexers/claude.session-indexer.ts new file mode 100644 index 00000000..3a8a680c --- /dev/null +++ b/server/src/modules/llm/session-indexers/claude.session-indexer.ts @@ -0,0 +1,84 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { + buildLookupMap, + extractFirstValidJsonlData, + findFilesRecursivelyCreatedAfter, + normalizeSessionName, + readFileTimestamps, +} from '@/modules/llm/session-indexers/session-indexer.utils.js'; +import type { ISessionIndexer } from '@/modules/llm/session-indexers/session-indexer.interface.js'; + +type ParsedSession = { + sessionId: string; + workspacePath: string; + sessionName?: string; +}; + +/** + * Session indexer for Claude transcript artifacts. + */ +export class ClaudeSessionIndexer implements ISessionIndexer { + readonly provider = 'claude' as const; + + /** + * Scans ~/.claude projects and upserts discovered sessions into DB. + */ + async synchronize(lastScanAt: Date | null): Promise { + const claudeHome = path.join(os.homedir(), '.claude'); + const nameMap = await buildLookupMap(path.join(claudeHome, 'history.jsonl'), 'sessionId', 'display'); + const files = await findFilesRecursivelyCreatedAfter( + path.join(claudeHome, 'projects'), + '.jsonl', + lastScanAt, + ); + + let processed = 0; + for (const filePath of files) { + const parsed = await this.processSessionFile(filePath, nameMap); + if (!parsed) { + continue; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + processed += 1; + } + + return processed; + } + + /** + * Extracts session metadata from one Claude JSONL session file. + */ + private async processSessionFile( + filePath: string, + nameMap: Map, + ): Promise { + return extractFirstValidJsonlData(filePath, (rawData) => { + const data = rawData as Record; + const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + const workspacePath = typeof data.cwd === 'string' ? data.cwd : undefined; + + if (!sessionId || !workspacePath) { + return null; + } + + return { + sessionId, + workspacePath, + sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'), + }; + }); + } +} diff --git a/server/src/modules/llm/session-indexers/codex.session-indexer.ts b/server/src/modules/llm/session-indexers/codex.session-indexer.ts new file mode 100644 index 00000000..65558103 --- /dev/null +++ b/server/src/modules/llm/session-indexers/codex.session-indexer.ts @@ -0,0 +1,85 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { + buildLookupMap, + extractFirstValidJsonlData, + findFilesRecursivelyCreatedAfter, + normalizeSessionName, + readFileTimestamps, +} from '@/modules/llm/session-indexers/session-indexer.utils.js'; +import type { ISessionIndexer } from '@/modules/llm/session-indexers/session-indexer.interface.js'; + +type ParsedSession = { + sessionId: string; + workspacePath: string; + sessionName?: string; +}; + +/** + * Session indexer for Codex transcript artifacts. + */ +export class CodexSessionIndexer implements ISessionIndexer { + readonly provider = 'codex' as const; + + /** + * Scans ~/.codex sessions and upserts discovered sessions into DB. + */ + async synchronize(lastScanAt: Date | null): Promise { + const codexHome = path.join(os.homedir(), '.codex'); + const nameMap = await buildLookupMap(path.join(codexHome, 'session_index.jsonl'), 'id', 'thread_name'); + const files = await findFilesRecursivelyCreatedAfter( + path.join(codexHome, 'sessions'), + '.jsonl', + lastScanAt, + ); + + let processed = 0; + for (const filePath of files) { + const parsed = await this.processSessionFile(filePath, nameMap); + if (!parsed) { + continue; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + processed += 1; + } + + return processed; + } + + /** + * Extracts session metadata from one Codex JSONL session file. + */ + private async processSessionFile( + filePath: string, + nameMap: Map, + ): Promise { + return extractFirstValidJsonlData(filePath, (rawData) => { + const data = rawData as Record; + const payload = data.payload as Record | undefined; + const sessionId = typeof payload?.id === 'string' ? payload.id : undefined; + const workspacePath = typeof payload?.cwd === 'string' ? payload.cwd : undefined; + + if (!sessionId || !workspacePath) { + return null; + } + + return { + sessionId, + workspacePath, + sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'), + }; + }); + } +} diff --git a/server/src/modules/llm/session-indexers/cursor.session-indexer.ts b/server/src/modules/llm/session-indexers/cursor.session-indexer.ts new file mode 100644 index 00000000..a2cdf968 --- /dev/null +++ b/server/src/modules/llm/session-indexers/cursor.session-indexer.ts @@ -0,0 +1,138 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; + +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { + extractFirstValidJsonlData, + findFilesRecursivelyCreatedAfter, + listDirectoryEntriesSafe, + normalizeSessionName, + readFileTimestamps, +} from '@/modules/llm/session-indexers/session-indexer.utils.js'; +import type { ISessionIndexer } from '@/modules/llm/session-indexers/session-indexer.interface.js'; + +type ParsedSession = { + sessionId: string; + workspacePath: string; + sessionName?: string; +}; + +/** + * Session indexer for Cursor transcript artifacts. + */ +export class CursorSessionIndexer implements ISessionIndexer { + readonly provider = 'cursor' as const; + + /** + * Scans Cursor chats and upserts discovered sessions into DB. + */ + async synchronize(lastScanAt: Date | null): Promise { + const cursorHome = path.join(os.homedir(), '.cursor'); + const projectsDir = path.join(cursorHome, 'projects'); + const projectEntries = await listDirectoryEntriesSafe(projectsDir); + const seenWorkspacePaths = new Set(); + + let processed = 0; + for (const entry of projectEntries) { + if (!entry.isDirectory()) { + continue; + } + + const workerLogPath = path.join(projectsDir, entry.name, 'worker.log'); + const workspacePath = await this.extractWorkspacePathFromWorkerLog(workerLogPath); + if (!workspacePath || seenWorkspacePaths.has(workspacePath)) { + continue; + } + + seenWorkspacePaths.add(workspacePath); + const workspaceHash = this.md5(workspacePath); + const chatsDir = path.join(cursorHome, 'chats', workspaceHash); + const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', lastScanAt); + + for (const filePath of files) { + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + continue; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + processed += 1; + } + } + + return processed; + } + + /** + * Produces the same workspace hash Cursor uses in chat directory names. + */ + private md5(input: string): string { + return crypto.createHash('md5').update(input).digest('hex'); + } + + /** + * Extracts workspace path from Cursor worker.log. + */ + private async extractWorkspacePathFromWorkerLog(filePath: string): Promise { + try { + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of lineReader) { + const match = line.match(/workspacePath=(.*)$/); + const workspacePath = match?.[1]?.trim(); + if (workspacePath) { + lineReader.close(); + fileStream.close(); + return workspacePath; + } + } + } catch { + // Missing worker logs are valid for partial/incomplete session data. + } + + return null; + } + + /** + * Extracts session metadata from one Cursor JSONL session file. + */ + private async processSessionFile(filePath: string): Promise { + const sessionId = path.basename(filePath, '.jsonl'); + const grandparentDir = path.dirname(path.dirname(filePath)); + const workerLogPath = path.join(grandparentDir, 'worker.log'); + const workspacePath = await this.extractWorkspacePathFromWorkerLog(workerLogPath); + + if (!workspacePath) { + return null; + } + + return extractFirstValidJsonlData(filePath, (rawData) => { + const data = rawData as Record; + if (data.role !== 'user') { + return null; + } + + const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : ''; + const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0]; + + return { + sessionId, + workspacePath, + sessionName: normalizeSessionName(firstLine, 'Untitled Cursor Session'), + }; + }); + } +} diff --git a/server/src/modules/llm/session-indexers/gemini.session-indexer.ts b/server/src/modules/llm/session-indexers/gemini.session-indexer.ts new file mode 100644 index 00000000..147c2727 --- /dev/null +++ b/server/src/modules/llm/session-indexers/gemini.session-indexer.ts @@ -0,0 +1,128 @@ +import os from 'node:os'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { + findFilesRecursivelyCreatedAfter, + normalizeSessionName, + readFileTimestamps, +} from '@/modules/llm/session-indexers/session-indexer.utils.js'; +import type { ISessionIndexer } from '@/modules/llm/session-indexers/session-indexer.interface.js'; + +type ParsedSession = { + sessionId: string; + workspacePath: string; + sessionName?: string; +}; + +/** + * Session indexer for Gemini transcript artifacts. + */ +export class GeminiSessionIndexer implements ISessionIndexer { + readonly provider = 'gemini' as const; + + /** + * Scans Gemini session JSON files and upserts discovered sessions into DB. + */ + async synchronize(lastScanAt: Date | null): Promise { + const geminiHome = path.join(os.homedir(), '.gemini'); + const legacySessionFiles = await findFilesRecursivelyCreatedAfter( + path.join(geminiHome, 'sessions'), + '.json', + lastScanAt, + ); + const tempFiles = await findFilesRecursivelyCreatedAfter( + path.join(geminiHome, 'tmp'), + '.json', + lastScanAt, + ); + const files = [...legacySessionFiles, ...tempFiles]; + + let processed = 0; + for (const filePath of files) { + if ( + filePath.startsWith(path.join(geminiHome, 'tmp')) && + !filePath.includes(`${path.sep}chats${path.sep}`) + ) { + continue; + } + + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + continue; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + processed += 1; + } + + return processed; + } + + /** + * Extracts session metadata from one Gemini JSON artifact. + */ + private async processSessionFile(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const data = JSON.parse(content) as Record; + + const sessionId = + typeof data.sessionId === 'string' + ? data.sessionId + : typeof data.id === 'string' + ? data.id + : undefined; + if (!sessionId) { + return null; + } + + let workspacePath = typeof data.projectPath === 'string' ? data.projectPath : ''; + + if (!workspacePath && filePath.includes(`${path.sep}chats${path.sep}`)) { + const chatsDir = path.dirname(filePath); + const workspaceDir = path.dirname(chatsDir); + const projectRootPath = path.join(workspaceDir, '.project_root'); + + try { + const rootContent = await readFile(projectRootPath, 'utf8'); + workspacePath = rootContent.trim(); + } catch { + // Some Gemini artifacts do not ship a .project_root marker. + } + } + + if (!workspacePath) { + return null; + } + + const messages = Array.isArray(data.messages) ? data.messages : []; + const firstMessage = messages[0] as Record | undefined; + let rawName: string | undefined; + + if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') { + rawName = firstMessage.content[0].text; + } else if (typeof firstMessage?.content === 'string') { + rawName = firstMessage.content; + } + + return { + sessionId, + workspacePath, + sessionName: normalizeSessionName(rawName, 'New Gemini Chat'), + }; + } catch { + return null; + } + } +} diff --git a/server/src/modules/llm/session-indexers/index.ts b/server/src/modules/llm/session-indexers/index.ts new file mode 100644 index 00000000..eb67bf49 --- /dev/null +++ b/server/src/modules/llm/session-indexers/index.ts @@ -0,0 +1,15 @@ +import type { ISessionIndexer } from '@/modules/llm/session-indexers/session-indexer.interface.js'; +import { ClaudeSessionIndexer } from '@/modules/llm/session-indexers/claude.session-indexer.js'; +import { CodexSessionIndexer } from '@/modules/llm/session-indexers/codex.session-indexer.js'; +import { CursorSessionIndexer } from '@/modules/llm/session-indexers/cursor.session-indexer.js'; +import { GeminiSessionIndexer } from '@/modules/llm/session-indexers/gemini.session-indexer.js'; + +/** + * Provider-specific session indexers used by the sync orchestrator. + */ +export const sessionIndexers: ISessionIndexer[] = [ + new ClaudeSessionIndexer(), + new CodexSessionIndexer(), + new CursorSessionIndexer(), + new GeminiSessionIndexer(), +]; diff --git a/server/src/modules/llm/session-indexers/session-indexer.interface.ts b/server/src/modules/llm/session-indexers/session-indexer.interface.ts new file mode 100644 index 00000000..1b6a6d20 --- /dev/null +++ b/server/src/modules/llm/session-indexers/session-indexer.interface.ts @@ -0,0 +1,13 @@ +import type { LLMProvider } from '@/shared/types/app.js'; + +/** + * Contract for provider-specific session indexing logic. + */ +export interface ISessionIndexer { + readonly provider: LLMProvider; + + /** + * Scans provider session artifacts and upserts discovered sessions into DB. + */ + synchronize(lastScanAt: Date | null): Promise; +} diff --git a/server/src/modules/llm/session-indexers/session-indexer.utils.ts b/server/src/modules/llm/session-indexers/session-indexer.utils.ts new file mode 100644 index 00000000..4767a88a --- /dev/null +++ b/server/src/modules/llm/session-indexers/session-indexer.utils.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline'; +/** + * Keeps extracted session names compact and UI-safe. + */ +export function normalizeSessionName(rawValue: string | undefined, fallback: string): string { + const normalized = (rawValue ?? '').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return fallback; + } + + return normalized.slice(0, 120); +} + +/** + * Returns directory entries or an empty array when the directory does not exist. + */ +export async function listDirectoryEntriesSafe( + directoryPath: string, +): Promise { + try { + return await fsp.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +/** + * Builds a lookup map from a JSONL index file by extracting a key/value pair per row. + * The first occurrence of a key wins so we preserve earliest metadata. + */ +export async function buildLookupMap( + filePath: string, + keyField: string, + valueField: string, +): Promise> { + const lookup = new Map(); + + try { + const fileStream = fs.createReadStream(filePath); + const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of lineReader) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const parsed = JSON.parse(trimmed) as Record; + const key = parsed[keyField]; + const value = parsed[valueField]; + + if (typeof key === 'string' && typeof value === 'string' && !lookup.has(key)) { + lookup.set(key, value); + } + } + } catch { + // Missing index files are normal for users who have not used a provider yet. + } + + return lookup; +} + +/** + * Recursively scans for files with a given extension and optionally filters + * them to only files created after `lastScanAt`. + */ +export async function findFilesRecursivelyCreatedAfter( + rootDir: string, + extension: string, + lastScanAt: Date | null, + fileList: string[] = [], +): Promise { + try { + const entries = await fsp.readdir(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + + if (entry.isDirectory()) { + await findFilesRecursivelyCreatedAfter(fullPath, extension, lastScanAt, fileList); + continue; + } + + if (!entry.isFile() || !entry.name.endsWith(extension)) { + continue; + } + + if (!lastScanAt) { + fileList.push(fullPath); + continue; + } + + const stats = await fsp.stat(fullPath); + if (stats.birthtime > lastScanAt) { + fileList.push(fullPath); + } + } + } catch { + // Missing provider directories should not fail the full sync. + } + + return fileList; +} + +/** + * Reads JSONL rows until the extractor yields a valid session identity. + */ +export async function extractFirstValidJsonlData( + filePath: string, + extractor: (parsedJson: unknown) => T | null | undefined, +): Promise { + try { + const fileStream = fs.createReadStream(filePath); + const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of lineReader) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const parsed = JSON.parse(trimmed); + const extracted = extractor(parsed); + if (extracted) { + lineReader.close(); + fileStream.close(); + return extracted; + } + } + } catch { + // Ignore malformed session files and continue scanning. + } + + return null; +} + +/** + * Reads filesystem timestamps for DB metadata fields. + */ +export async function readFileTimestamps( + filePath: string, +): Promise<{ createdAt?: string; updatedAt?: string }> { + try { + const stat = await fsp.stat(filePath); + return { + createdAt: stat.birthtime.toISOString(), + updatedAt: stat.mtime.toISOString(), + }; + } catch { + return {}; + } +} diff --git a/server/src/modules/llm/sessions.service.ts b/server/src/modules/llm/sessions.service.ts new file mode 100644 index 00000000..7cc4a4ee --- /dev/null +++ b/server/src/modules/llm/sessions.service.ts @@ -0,0 +1,236 @@ +import path from 'node:path'; +import fsp, { readFile } from 'node:fs/promises'; + +import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import type { LLMProvider } from '@/shared/types/app.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import { sessionIndexers } from '@/modules/llm/session-indexers/index.js'; + +type SyncResult = { + processedByProvider: Record; + failures: string[]; +}; + +type SessionHistoryPayload = { + sessionId: string; + provider: string; + workspacePath: string; + filePath: string; + fileType: 'jsonl' | 'json'; + entries: unknown[]; +}; + +const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/; + +/** + * Restricts session IDs before they are used in DB/filesystem operations. + */ +function sanitizeSessionId(sessionId: string): string { + const value = String(sessionId).trim(); + if (!SESSION_ID_PATTERN.test(value)) { + throw new AppError('Invalid session ID format.', { + code: 'INVALID_SESSION_ID', + statusCode: 400, + }); + } + return value; +} + +/** + * Removes one file if it exists. + */ +async function removeFileIfExists(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return false; + } + throw error; + } +} + +/** + * Parses newline-delimited JSON files and preserves malformed lines as raw entries. + */ +const parseJsonl = (content: string): unknown[] => { + const entries: unknown[] = []; + const lines = content.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + try { + entries.push(JSON.parse(trimmed)); + } catch { + entries.push({ raw: trimmed, parseError: true }); + } + } + + return entries; +}; + +/** + * Parses JSON files and normalizes object payloads into a single-element array. + */ +const parseJson = (content: string): unknown[] => { + try { + const parsed = JSON.parse(content) as unknown; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return [{ raw: content, parseError: true }]; + } +}; + +/** + * Orchestrates provider-specific session indexers and DB-path based cleanup. + */ +export const llmSessionsService = { + /** + * Lists indexed sessions from the shared DB, optionally scoped to one provider. + */ + listIndexedSessions(provider?: string) { + const allSessions = sessionsDb.getAllSessions(); + if (!provider) { + return allSessions; + } + + return allSessions.filter((session) => session.provider === provider); + }, + + /** + * Runs all provider indexers and updates `scan_state.last_scanned_at`. + */ + async synchronizeSessions(): Promise { + const lastScanAt = scanStateDb.getLastScannedAt(); + const processedByProvider: Record = { + claude: 0, + codex: 0, + cursor: 0, + gemini: 0, + }; + const failures: string[] = []; + + const results = await Promise.allSettled( + sessionIndexers.map(async (indexer) => ({ + provider: indexer.provider, + processed: await indexer.synchronize(lastScanAt), + })), + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + processedByProvider[result.value.provider] = result.value.processed; + continue; + } + + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + failures.push(reason); + } + + scanStateDb.updateLastScannedAt(); + + return { + processedByProvider, + failures, + }; + }, + + /** + * Runs one provider indexer and updates `scan_state.last_scanned_at`. + */ + async synchronizeProvider( + provider: LLMProvider, + options: { fullRescan?: boolean } = {}, + ): Promise<{ provider: LLMProvider; processed: number }> { + const indexer = sessionIndexers.find((entry) => entry.provider === provider); + if (!indexer) { + throw new AppError(`No session indexer registered for provider "${provider}".`, { + code: 'SESSION_INDEXER_NOT_FOUND', + statusCode: 500, + }); + } + + const lastScanAt = options.fullRescan ? null : scanStateDb.getLastScannedAt(); + const processed = await indexer.synchronize(lastScanAt); + scanStateDb.updateLastScannedAt(); + + return { provider, processed }; + }, + + updateSessionCustomName(sessionId: string, sessionCustomName: string): void { + const sessionMetadata = sessionsDb.getSessionById(sessionId); + if (!sessionMetadata) { + throw new AppError('Session not found.', { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + sessionsDb.updateSessionCustomName(sessionId, sessionCustomName); + }, + + /** + * Deletes a session artifact using only DB `jsonl_path`, then removes the DB row. + */ + async deleteSessionArtifacts(rawSessionId: string): Promise<{ + sessionId: string; + deletedFromDisk: boolean; + deletedFromDatabase: boolean; + }> { + const sessionId = sanitizeSessionId(rawSessionId); + const existingSession = sessionsDb.getSessionById(sessionId); + const jsonlPath = existingSession?.jsonl_path ?? null; + const deletedFromDisk = jsonlPath ? await removeFileIfExists(jsonlPath) : false; + + if (existingSession) { + sessionsDb.deleteSession(sessionId); + } + + return { + sessionId, + deletedFromDisk, + deletedFromDatabase: Boolean(existingSession), + }; + }, + + /** + * Reads session history directly from `sessions.jsonl_path` without legacy fetchers. + */ + async getSessionHistory(sessionId: string): Promise { + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + if (!session.jsonl_path) { + throw new AppError(`Session "${sessionId}" does not have a history file path.`, { + code: 'SESSION_HISTORY_NOT_AVAILABLE', + statusCode: 404, + }); + } + + const filePath = session.jsonl_path; + const fileContent = await readFile(filePath, 'utf8'); + const extension = path.extname(filePath).toLowerCase(); + const isGeminiJson = session.provider === 'gemini' || extension === '.json'; + + return { + sessionId: session.session_id, + provider: session.provider, + workspacePath: session.workspace_path, + filePath, + fileType: isGeminiJson ? 'json' : 'jsonl', + entries: isGeminiJson ? parseJson(fileContent) : parseJsonl(fileContent), + }; + }, +}; diff --git a/server/src/modules/llm/sessions.watcher.ts b/server/src/modules/llm/sessions.watcher.ts new file mode 100644 index 00000000..29781142 --- /dev/null +++ b/server/src/modules/llm/sessions.watcher.ts @@ -0,0 +1,131 @@ +import chokidar from 'chokidar'; +import os from 'node:os'; +import path from 'node:path'; +import { promises as fsPromises } from 'node:fs'; + +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import type { LLMProvider } from '@/shared/types/app.js'; +import { logger } from '@/shared/utils/logger.js'; + +// File system watchers for provider project/session folders +const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [ + { + provider: 'claude', + rootPath: path.join(os.homedir(), '.claude', 'projects'), + }, + { + provider: 'cursor', + rootPath: path.join(os.homedir(), '.cursor', 'chats'), + }, + { + provider: 'codex', + rootPath: path.join(os.homedir(), '.codex', 'sessions'), + }, + { + provider: 'gemini', + rootPath: path.join(os.homedir(), '.gemini', 'sessions'), + }, + { + provider: 'gemini', + rootPath: path.join(os.homedir(), '.gemini', 'tmp'), + }, +]; + +const WATCHER_IGNORED_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/*.tmp', + '**/*.swp', + '**/.DS_Store', +]; + + +const watchers: any[] = []; +type EventType = 'add' | 'change'; + +/** + * Handles watcher update events and triggers provider index synchronization. + */ +async function onUpdate( + eventType: EventType, + filePath: string, + provider: LLMProvider, +): Promise { + try { + const result = await llmSessionsService.synchronizeProvider(provider, { fullRescan: true }); + logger.info(`LLM watcher sync complete for provider "${provider}" after ${eventType}`, { + filePath, + processed: result.processed, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`LLM watcher sync failed for provider "${provider}"`, { + eventType, + filePath, + error: message, + }); + } +} + +/** + * Initializes LLM session watchers and performs an initial index sync. + */ +export async function initializeWatcher(): Promise { + logger.info('Setting up LLM session watchers...'); + + const initialSync = await llmSessionsService.synchronizeSessions(); + logger.info('Initial LLM session sync complete.', { + processedByProvider: initialSync.processedByProvider, + failures: initialSync.failures, + }); + + for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { + try { + // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. + // Ensure provider folders exist before creating the watcher so watching stays active. + await fsPromises.mkdir(rootPath, { recursive: true }); + + const watcher = chokidar.watch(rootPath, { + ignored: WATCHER_IGNORED_PATTERNS, + persistent: true, + // Don't fire events for existing files on startup + ignoreInitial: true, + followSymlinks: false, + // Reasonable depth limit + depth: 6, + // Use polling to fix Windows fs.watch buffering/batching issues. + // It now stops relying on native filesystem events and checks for changes at intervals. + usePolling: true, + // Poll every 2000ms + interval: 2_000, + // Large binary files are more expensive to poll than text files. + binaryInterval: 6_000, + // Removed awaitWriteFinish to prevent delays when LLM streams to the file + }); + + watcher + .on('add', (filePath: string) => { + void onUpdate('add', filePath, provider); + }) + .on('change', (filePath: string) => { + void onUpdate('change', filePath, provider); + }) + .on('error', (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + logger.error(`LLM watcher error for provider "${provider}"`, { + error: message, + }); + }); + + watchers.push(watcher); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Failed to initialize LLM watcher for provider "${provider}"`, { + rootPath, + error: message, + }); + } + } +} diff --git a/server/src/modules/projects/projects.inline.routes.js b/server/src/modules/projects/projects.inline.routes.js index 2e1220da..336ac110 100644 --- a/server/src/modules/projects/projects.inline.routes.js +++ b/server/src/modules/projects/projects.inline.routes.js @@ -11,7 +11,7 @@ import { } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; -import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; import { authenticateToken } from '../auth/auth.middleware.js'; import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js'; @@ -69,7 +69,7 @@ router.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToke try { const { projectName, sessionId } = req.params; console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); - await deleteSessionFromProviders(sessionId); + await llmSessionsService.deleteSessionArtifacts(sessionId); console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); } catch (error) { diff --git a/server/src/modules/providers/claude/.gitkeep b/server/src/modules/providers/claude/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/claude/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/providers/claude/claude.session-processor.ts b/server/src/modules/providers/claude/claude.session-processor.ts deleted file mode 100644 index 31ef68fd..00000000 --- a/server/src/modules/providers/claude/claude.session-processor.ts +++ /dev/null @@ -1,116 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fsp from 'node:fs/promises'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -export async function processClaudeSessionFile(file: string, nameMap?: Map): Promise { - if (!nameMap) { - const base = path.join(os.homedir(), '.claude'); - nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); - } - - // Claude puts cwd and sessionId directly on the root object - return extractFirstValidJsonlData(file, (data) => ({ - workspacePath: data?.cwd, - sessionId: data?.sessionId, - sessionName: nameMap!.get(data?.sessionId) || 'Untitled Claude Session' - })); -} - -export async function processClaudeSessions() { - const base = path.join(os.homedir(), '.claude'); - // Pre-load names from history index - const nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); - - const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'projects'), '.jsonl'); - for (const file of files) { - const result = await processClaudeSessionFile(file, nameMap); - - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'claude', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - file, - ); - } - } -} - -function encodeClaudeProjectPath(projectPath: string): string { - return projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); -} - -async function removeFileIfExists(filePath: string): Promise { - try { - await fsp.unlink(filePath); - return true; - } catch (error: any) { - if (error?.code === 'ENOENT') { - return false; - } - throw error; - } -} - -async function listDirectoryEntriesSafe(directoryPath: string): Promise { - try { - return await fsp.readdir(directoryPath, { withFileTypes: true }); - } catch { - return []; - } -} - -async function findFilesByName(rootPath: string, fileName: string): Promise { - const matches: string[] = []; - const stack = [rootPath]; - - while (stack.length > 0) { - const currentPath = stack.pop() as string; - const entries = await listDirectoryEntriesSafe(currentPath); - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - } else if (entry.isFile() && entry.name === fileName) { - matches.push(fullPath); - } - } - } - - return matches; -} - -export async function deleteClaudeSession(sessionId: string, workspacePath?: string): Promise { - const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects'); - const fileName = `${sessionId}.jsonl`; - let deleted = false; - - if (workspacePath) { - const encodedPath = encodeClaudeProjectPath(workspacePath); - const candidateFilePath = path.join(claudeProjectsDir, encodedPath, fileName); - deleted = (await removeFileIfExists(candidateFilePath)) || deleted; - } - - const matches = await findFilesByName(claudeProjectsDir, fileName); - for (const filePath of matches) { - deleted = (await removeFileIfExists(filePath)) || deleted; - } - - return deleted; -} diff --git a/server/src/modules/providers/codex/.gitkeep b/server/src/modules/providers/codex/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/codex/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/providers/codex/codex.session-processor.ts b/server/src/modules/providers/codex/codex.session-processor.ts deleted file mode 100644 index 8bd76065..00000000 --- a/server/src/modules/providers/codex/codex.session-processor.ts +++ /dev/null @@ -1,178 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fsp from 'node:fs/promises'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -export async function processCodexSessionFile(file: string, nameMap?: Map): Promise { - if (!nameMap) { - const base = path.join(os.homedir(), '.codex'); - nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - } - - // Codex nests the required data inside a `payload` object - return extractFirstValidJsonlData(file, (data) => ({ - workspacePath: data?.payload?.cwd, - sessionId: data?.payload?.id, - sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session' - })); -} - -export async function processCodexSessions() { - const base = path.join(os.homedir(), '.codex'); - // Use the thread_name attribute as requested - const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - - const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl'); - - for (const file of files) { - const result = await processCodexSessionFile(file, nameMap); - - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'codex', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - file, - ); - } - } -} - -function getPathNumberVariants(value: number): string[] { - const unpadded = String(value); - const padded = unpadded.padStart(2, '0'); - - if (unpadded === padded) { - return [unpadded]; - } - - return [unpadded, padded]; -} - -function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> { - const parsedDate = new Date(createdAt); - if (Number.isNaN(parsedDate.getTime())) { - return []; - } - - const localDate = { - year: String(parsedDate.getFullYear()), - month: parsedDate.getMonth() + 1, - day: parsedDate.getDate(), - }; - - const utcDate = { - year: String(parsedDate.getUTCFullYear()), - month: parsedDate.getUTCMonth() + 1, - day: parsedDate.getUTCDate(), - }; - - const rawDateParts = - localDate.year === utcDate.year && - localDate.month === utcDate.month && - localDate.day === utcDate.day - ? [localDate] - : [localDate, utcDate]; - - const uniqueDateParts = new Map(); - for (const datePart of rawDateParts) { - const monthVariants = getPathNumberVariants(datePart.month); - const dayVariants = getPathNumberVariants(datePart.day); - - for (const month of monthVariants) { - for (const day of dayVariants) { - uniqueDateParts.set(`${datePart.year}-${month}-${day}`, { - year: datePart.year, - month, - day, - }); - } - } - } - - return [...uniqueDateParts.values()]; -} - -async function removeFileIfExists(filePath: string): Promise { - try { - await fsp.unlink(filePath); - return true; - } catch (error: any) { - if (error?.code === 'ENOENT') { - return false; - } - throw error; - } -} - -async function listDirectoryEntriesSafe(directoryPath: string): Promise { - try { - return await fsp.readdir(directoryPath, { withFileTypes: true }); - } catch { - return []; - } -} - -async function findFilesByName(rootPath: string, fileName: string): Promise { - const matches: string[] = []; - const stack = [rootPath]; - - while (stack.length > 0) { - const currentPath = stack.pop() as string; - const entries = await listDirectoryEntriesSafe(currentPath); - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - } else if (entry.isFile() && entry.name === fileName) { - matches.push(fullPath); - } - } - } - - return matches; -} - -export async function deleteCodexSession(sessionId: string, createdAt?: string): Promise { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const fileName = `${sessionId}.jsonl`; - let deleted = false; - - if (createdAt) { - const datePathParts = buildCodexDatePathParts(createdAt); - for (const parts of datePathParts) { - const candidateFilePath = path.join( - codexSessionsDir, - parts.year, - parts.month, - parts.day, - fileName, - ); - deleted = (await removeFileIfExists(candidateFilePath)) || deleted; - } - } - - if (!deleted) { - const matches = await findFilesByName(codexSessionsDir, fileName); - for (const filePath of matches) { - deleted = (await removeFileIfExists(filePath)) || deleted; - } - } - - return deleted; -} diff --git a/server/src/modules/providers/cursor/.gitkeep b/server/src/modules/providers/cursor/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/cursor/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/providers/cursor/cursor.session-processor.ts b/server/src/modules/providers/cursor/cursor.session-processor.ts deleted file mode 100644 index 8b1a1d9f..00000000 --- a/server/src/modules/providers/cursor/cursor.session-processor.ts +++ /dev/null @@ -1,208 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import readline from 'readline'; -import crypto from 'node:crypto'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -function md5(input: string): string { - return crypto.createHash('md5').update(input).digest('hex'); -} - -async function removeFileIfExists(filePath: string): Promise { - try { - await fsp.unlink(filePath); - return true; - } catch (error: any) { - if (error?.code === 'ENOENT') { - return false; - } - throw error; - } -} - -async function removeDirectoryIfExists(directoryPath: string): Promise { - try { - await fsp.rm(directoryPath, { recursive: true, force: false }); - return true; - } catch (error: any) { - if (error?.code === 'ENOENT') { - return false; - } - throw error; - } -} - -async function listDirectoryEntriesSafe(directoryPath: string): Promise { - try { - return await fsp.readdir(directoryPath, { withFileTypes: true }); - } catch { - return []; - } -} - -async function findDirectoriesByName(rootPath: string, directoryName: string): Promise { - const matches: string[] = []; - const stack = [rootPath]; - - while (stack.length > 0) { - const currentPath = stack.pop() as string; - const entries = await listDirectoryEntriesSafe(currentPath); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const fullPath = path.join(currentPath, entry.name); - if (entry.name === directoryName) { - matches.push(fullPath); - } - - stack.push(fullPath); - } - } - - return matches; -} - -async function findFilesByName(rootPath: string, fileName: string): Promise { - const matches: string[] = []; - const stack = [rootPath]; - - while (stack.length > 0) { - const currentPath = stack.pop() as string; - const entries = await listDirectoryEntriesSafe(currentPath); - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - if (entry.isDirectory()) { - stack.push(fullPath); - } else if (entry.isFile() && entry.name === fileName) { - matches.push(fullPath); - } - } - } - - return matches; -} - -export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise { - try { - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); - - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - const match = line.match(/workspacePath=(.*)$/); - const firstMatch = match?.[1]; - - if (firstMatch) { - rl.close(); - fileStream.close(); - return firstMatch; - } - } - } catch { - // ignore errors - } - - return null; -} - -export async function processCursorSessionFile(file: string): Promise { - const sessionId = path.basename(file, '.jsonl'); - const grandparentDir = path.dirname(path.dirname(file)); - const workerLogPath = path.join(grandparentDir, 'worker.log'); - const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); - - if (!workspacePath) return null; - - return extractFirstValidJsonlData(file, (lineJson) => { - if (lineJson.role === 'user') { - const rawText = lineJson.message?.content?.[0]?.text || ''; - // Strip tags and trim - const cleanName = rawText.replace(/<\/?user_query>/g, '').trim().split('\n'); - return { sessionId: sessionId as string, workspacePath, sessionName: cleanName[0] || "Untitled Cursor Session" }; - } - return null; - }); -} - -export async function processCursorSessions() { - try { - const cursorBase = path.join(os.homedir(), '.cursor'); - const projectsDir = path.join(cursorBase, 'projects'); - const projectDirs = await fsp.readdir(projectsDir); - const seenWorkspacePaths = new Set(); - - for (const projectDir of projectDirs) { - const workerLogPath = path.join(projectsDir, projectDir, 'worker.log'); - const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); - - if (!workspacePath || seenWorkspacePaths.has(workspacePath)) continue; - - seenWorkspacePaths.add(workspacePath); - const workspaceHash = md5(workspacePath); - const chatsDir = path.join(cursorBase, 'chats', workspaceHash); - - const sessionFiles = await findFilesRecursivelyCreatedAfterLastScan(chatsDir, '.jsonl'); - - for (const file of sessionFiles) { - const result = await processCursorSessionFile(file); - - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'cursor', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - file, - ); - } - } - } - } catch (e) { - // Base cursor directory or projects directory likely doesn't exist - } -} - -export async function deleteCursorSession(sessionId: string, workspacePath?: string): Promise { - const cursorChatsDir = path.join(os.homedir(), '.cursor', 'chats'); - let deleted = false; - - if (workspacePath) { - const cwdId = md5(workspacePath); - const candidateDir = path.join(cursorChatsDir, cwdId, sessionId); - deleted = (await removeDirectoryIfExists(candidateDir)) || deleted; - } - - const sessionDirs = await findDirectoriesByName(cursorChatsDir, sessionId); - for (const directoryPath of sessionDirs) { - deleted = (await removeDirectoryIfExists(directoryPath)) || deleted; - } - - const jsonlFiles = await findFilesByName(cursorChatsDir, `${sessionId}.jsonl`); - for (const filePath of jsonlFiles) { - deleted = (await removeFileIfExists(filePath)) || deleted; - } - - return deleted; -} diff --git a/server/src/modules/providers/gemini/gemini.session-processor.ts b/server/src/modules/providers/gemini/gemini.session-processor.ts deleted file mode 100644 index 6a9da1a6..00000000 --- a/server/src/modules/providers/gemini/gemini.session-processor.ts +++ /dev/null @@ -1,172 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fsp from 'node:fs/promises'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -export async function processGeminiSessionFile(file: string): Promise { - try { - const fileContent = await fsp.readFile(file, 'utf8'); - const data = JSON.parse(fileContent); - - // Check for new format: data.sessionId - // Fallback for old format: data.id and data.projectPath - if (data?.sessionId || (data?.id && data?.projectPath)) { - let sessionId = data.sessionId || data.id; - let workspacePath = data.projectPath || ''; - let sessionName = 'New Gemini Chat'; - - // Extract workspacePath for new format - if (data?.sessionId && file.includes(`${path.sep}chats${path.sep}`)) { - const chatsDir = path.dirname(file); - const workspaceDir = path.dirname(chatsDir); - const projectRootFile = path.join(workspaceDir, '.project_root'); - - try { - const rootContent = await fsp.readFile(projectRootFile, 'utf8'); - if (rootContent) { - workspacePath = rootContent.trim(); - } - } catch (e) { - // Ignore if .project_root doesn't exist - } - } - - // Extract sessionName - if (data.messages && Array.isArray(data.messages) && data.messages.length > 0) { - const firstMessage = data.messages[0]; - if (firstMessage?.content && Array.isArray(firstMessage.content) && firstMessage.content.length > 0) { - sessionName = firstMessage.content[0]?.text?.trim() || sessionName; - } else if (firstMessage?.content && typeof firstMessage.content === 'string') { - sessionName = firstMessage.content.trim() || sessionName; - } - } else if (data.messages?.[0]?.content) { - // old format fallback - sessionName = data.messages[0].content; - } - - // Clean up sessionName - if (sessionName) { - sessionName = sessionName.replace(/\n/g, ' ').trim().substring(0, 100); - } - - return { - sessionId, - workspacePath, - sessionName - }; - } - } catch (e) { - // Ignore parsing error for gemini - } - return null; -} - -export async function processGeminiSessions() { - const geminiHome = path.join(os.homedir(), '.gemini'); - - // Process old sessions directory - const oldGeminiPath = path.join(geminiHome, 'sessions'); - const oldFiles = await findFilesRecursivelyCreatedAfterLastScan(oldGeminiPath, '.json'); - - // Process new tmp/chats directories - const tmpGeminiPath = path.join(geminiHome, 'tmp'); - const tmpFiles = await findFilesRecursivelyCreatedAfterLastScan(tmpGeminiPath, '.json'); - - const files = [...oldFiles, ...tmpFiles]; - - for (const file of files) { - // For tmp files, only process those inside a 'chats' directory - if (file.startsWith(tmpGeminiPath) && !file.includes(`${path.sep}chats${path.sep}`)) { - continue; - } - - const result = await processGeminiSessionFile(file); - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'gemini', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - file, - ); - } - } -} - -async function removeFileIfExists(filePath: string): Promise { - try { - await fsp.unlink(filePath); - return true; - } catch (error: any) { - if (error?.code === 'ENOENT') { - return false; - } - throw error; - } -} - -async function listDirectoryEntriesSafe(directoryPath: string): Promise { - try { - return await fsp.readdir(directoryPath, { withFileTypes: true }); - } catch { - return []; - } -} - -export async function deleteGeminiSession(sessionId: string): Promise { - const geminiHome = path.join(os.homedir(), '.gemini'); - const geminiSessionsDir = path.join(geminiHome, 'sessions'); - const geminiTmpDir = path.join(geminiHome, 'tmp'); - let deleted = false; - - deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.json`))) || deleted; - deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.jsonl`))) || deleted; - - const projectDirs = await listDirectoryEntriesSafe(geminiTmpDir); - for (const projectDir of projectDirs) { - if (!projectDir.isDirectory()) { - continue; - } - - const chatsDir = path.join(geminiTmpDir, projectDir.name, 'chats'); - const chatFiles = await listDirectoryEntriesSafe(chatsDir); - - for (const chatFile of chatFiles) { - if (!chatFile.isFile() || !chatFile.name.endsWith('.json')) { - continue; - } - - const chatFilePath = path.join(chatsDir, chatFile.name); - if (chatFile.name === `${sessionId}.json`) { - deleted = (await removeFileIfExists(chatFilePath)) || deleted; - continue; - } - - try { - const content = await fsp.readFile(chatFilePath, 'utf8'); - const parsed = JSON.parse(content); - const parsedId = parsed?.sessionId || parsed?.id; - if (parsedId === sessionId) { - deleted = (await removeFileIfExists(chatFilePath)) || deleted; - } - } catch { - // Ignore unreadable/malformed session files. - } - } - } - - return deleted; -} diff --git a/server/src/modules/providers/shared/session-parser.utils.ts b/server/src/modules/providers/shared/session-parser.utils.ts deleted file mode 100644 index 856e1eb9..00000000 --- a/server/src/modules/providers/shared/session-parser.utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import readline from 'readline'; -import path from 'path'; -import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; -import { SessionData } from '@/shared/types/session.js'; - -/** - * Reads a JSONL file and builds a Map of Key -> Value. - * Useful for index files like history.jsonl or session_index.jsonl. - */ -export async function buildLookupMap(filePath: string, keyField: string, valueField: string): Promise> { - const lookup = new Map(); - try { - const fileStream = fs.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - if (!line.trim()) continue; - const data = JSON.parse(line); - // We use the first occurrence. In history files, this is usually the start of the thread. - if (data[keyField] && data[valueField] && !lookup.has(data[keyField])) { - lookup.set(data[keyField], data[valueField]); - } - } - } catch (e) { /* File might not exist yet */ } - return lookup; -} - -/** - * Recursively walks a directory tree and returns a flat array of all files - * matching a specific extension (e.g., '.jsonl' or '.json'). - * It will only find the files created after - */ -export async function findFilesRecursivelyCreatedAfterLastScan( - dirPath: string, - extension: string, - fileList: string[] = [] -): Promise { - try { - const entries = await fsp.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - if (entry.isDirectory()) { - await findFilesRecursivelyCreatedAfterLastScan(fullPath, extension, fileList); - } else if (entry.isFile() && entry.name.endsWith(extension)) { - const lastScanDate = scanStateDb.getLastScannedAt(); - - if (lastScanDate) { - // Check file CREATION time (birthtime) against our last scan time - const stats = await fsp.stat(fullPath); - if (stats.birthtime > lastScanDate) { - fileList.push(fullPath); - } - } else { - fileList.push(fullPath); - } - } - } - } catch (e) { - // Fail silently for directories that don't exist or lack read permissions - } - return fileList; -} - -/** - * Reads a file line-by-line, parsing each line as JSON. - * It passes the parsed JSON to a custom `extractorFn`. As soon as the extractor - * successfully finds both a sessionId and workspacePath, it closes the file and returns. - */ -export async function extractFirstValidJsonlData( - filePath: string, - extractorFn: (parsedJson: any) => Partial | null | undefined -): Promise { - try { - const fileStream = fs.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - if (!line.trim()) continue; - const parsedData = JSON.parse(line); - const extracted = extractorFn(parsedData); - - // If our custom extractor found what we need, return early - if (extracted?.sessionId && extracted?.workspacePath) { - rl.close(); - fileStream.close(); - return extracted as SessionData; - } - } - } catch (e) { - // Ignored errors - } - return null; -} diff --git a/server/src/modules/sessions/.gitkeep b/server/src/modules/sessions/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/sessions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/sessions/sessions.inline.routes.js b/server/src/modules/sessions/sessions.inline.routes.js deleted file mode 100644 index 2f58cda1..00000000 --- a/server/src/modules/sessions/sessions.inline.routes.js +++ /dev/null @@ -1,226 +0,0 @@ -import express from 'express'; -import path from 'path'; -import os from 'os'; -import { promises as fsPromises } from 'fs'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { extractProjectDirectory } from '../../../projects.js'; -import { authenticateToken } from '../auth/auth.middleware.js'; - -const router = express.Router(); -const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; - -// Rename session endpoint -router.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => { - try { - const { sessionId } = req.params; - const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safeSessionId || safeSessionId !== String(sessionId)) { - return res.status(400).json({ error: 'Invalid sessionId' }); - } - const { summary, provider } = req.body; - if (!summary || typeof summary !== 'string' || summary.trim() === '') { - return res.status(400).json({ error: 'Summary is required' }); - } - if (summary.trim().length > 500) { - return res.status(400).json({ error: 'Summary must not exceed 500 characters' }); - } - if (provider && !VALID_PROVIDERS.includes(provider)) { - return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` }); - } - sessionsDb.updateSessionCustomName(safeSessionId, summary.trim()); - res.json({ success: true }); - } catch (error) { - console.error(`[API] Error renaming session ${req.params.sessionId}:`, error); - res.status(500).json({ error: error.message }); - } -}); - -// Get token usage for a specific session -router.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { - try { - const { projectName, sessionId } = req.params; - const { provider = 'claude' } = req.query; - const homeDir = os.homedir(); - - // Allow only safe characters in sessionId - const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safeSessionId || safeSessionId !== String(sessionId)) { - return res.status(400).json({ error: 'Invalid sessionId' }); - } - - // Handle Cursor sessions - they use SQLite and don't have token usage info - if (provider === 'cursor') { - return res.json({ - used: 0, - total: 0, - breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, - unsupported: true, - message: 'Token usage tracking not available for Cursor sessions' - }); - } - - // Handle Gemini sessions - they are raw logs in our current setup - if (provider === 'gemini') { - return res.json({ - used: 0, - total: 0, - breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, - unsupported: true, - message: 'Token usage tracking not available for Gemini sessions' - }); - } - - // Handle Codex sessions - if (provider === 'codex') { - const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); - - // Find the session file by searching for the session ID - const findSessionFile = async (dir) => { - try { - const entries = await fsPromises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const found = await findSessionFile(fullPath); - if (found) return found; - } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { - return fullPath; - } - } - } catch (error) { - // Skip directories we can't read - } - return null; - }; - - const sessionFilePath = await findSessionFile(codexSessionsDir); - - if (!sessionFilePath) { - return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); - } - - // Read and parse the Codex JSONL file - let fileContent; - try { - fileContent = await fsPromises.readFile(sessionFilePath, 'utf8'); - } catch (error) { - if (error.code === 'ENOENT') { - return res.status(404).json({ error: 'Session file not found', path: sessionFilePath }); - } - throw error; - } - const lines = fileContent.trim().split('\n'); - let totalTokens = 0; - let contextWindow = 200000; // Default for Codex/OpenAI - - // Find the latest token_count event with info (scan from end) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const entry = JSON.parse(lines[i]); - - // Codex stores token info in event_msg with type: "token_count" - if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { - const tokenInfo = entry.payload.info; - if (tokenInfo.total_token_usage) { - totalTokens = tokenInfo.total_token_usage.total_tokens || 0; - } - if (tokenInfo.model_context_window) { - contextWindow = tokenInfo.model_context_window; - } - break; // Stop after finding the latest token count - } - } catch (parseError) { - // Skip lines that can't be parsed - continue; - } - } - - return res.json({ - used: totalTokens, - total: contextWindow - }); - } - - // Handle Claude sessions (default) - // Extract actual project path - let projectPath; - try { - projectPath = await extractProjectDirectory(projectName); - } catch (error) { - console.error('Error extracting project directory:', error); - return res.status(500).json({ error: 'Failed to determine project path' }); - } - - // Construct the JSONL file path - // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl - // The encoding replaces any non-alphanumeric character (except -) with - - const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); - const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - - const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); - - // Constrain to projectDir - const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - return res.status(400).json({ error: 'Invalid path' }); - } - - // Read and parse the JSONL file - let fileContent; - try { - fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); - } catch (error) { - if (error.code === 'ENOENT') { - return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); - } - throw error; // Re-throw other errors to be caught by outer try-catch - } - const lines = fileContent.trim().split('\n'); - - const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); - const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; - let inputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - - // Find the latest assistant message with usage data (scan from end) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const entry = JSON.parse(lines[i]); - - // Only count assistant messages which have usage data - if (entry.type === 'assistant' && entry.message?.usage) { - const usage = entry.message.usage; - - // Use token counts from latest assistant message only - inputTokens = usage.input_tokens || 0; - cacheCreationTokens = usage.cache_creation_input_tokens || 0; - cacheReadTokens = usage.cache_read_input_tokens || 0; - - break; // Stop after finding the latest assistant message - } - } catch (parseError) { - // Skip lines that can't be parsed - continue; - } - } - - // Calculate total context usage (excluding output_tokens, as per ccusage) - const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; - - res.json({ - used: totalUsed, - total: contextWindow, - breakdown: { - input: inputTokens, - cacheCreation: cacheCreationTokens, - cacheRead: cacheReadTokens - } - }); - } catch (error) { - console.error('Error reading session token usage:', error); - res.status(500).json({ error: 'Failed to read session token usage' }); - } -}); - -export default router; diff --git a/server/src/modules/sessions/sessions.service.ts b/server/src/modules/sessions/sessions.service.ts deleted file mode 100644 index c47b86be..00000000 --- a/server/src/modules/sessions/sessions.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { processClaudeSessions, deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js'; -import { processCodexSessions, deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js'; -import { processGeminiSessions, deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js'; -import { processCursorSessions, deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js'; - -const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/; - -function sanitizeSessionId(sessionId: string): string { - const value = String(sessionId || '').trim(); - if (!SESSION_ID_PATTERN.test(value)) { - throw new Error('Invalid session ID format'); - } - return value; -} - -export async function processSessions() { - - // 1. Start the timer with a unique label - console.time('Workspace sync total time'); - - console.log('Starting workspace sync...'); - try { - // Wrapping in Promise.all allows these to process concurrently, speeding up the boot time - await Promise.allSettled([ - processClaudeSessions(), - processCodexSessions(), - processGeminiSessions(), - processCursorSessions() - ]); - - scanStateDb.updateLastScannedAt(); - } catch (error) { - console.error('An error occurred during sync:', error); - } finally { - console.log('----------------------------------'); - // 2. Stop the timer using the exact same label - // This will print: Workspace sync total time: 123.456ms - console.timeEnd('Workspace sync total time'); - console.log('Workspace synchronization complete.'); - } -} - -export async function deleteSession(sessionId: string): Promise { - const safeSessionId = sanitizeSessionId(sessionId); - const existingSession = sessionsDb.getSessionById(safeSessionId); - const workspacePath = existingSession?.workspace_path; - const createdAt = existingSession?.created_at; - - const deletionResults = await Promise.allSettled([ - deleteClaudeSession(safeSessionId, workspacePath), - deleteCodexSession(safeSessionId, createdAt), - deleteGeminiSession(safeSessionId), - deleteCursorSession(safeSessionId, workspacePath), - ]); - - const rejectedResult = deletionResults.find((result) => result.status === 'rejected') as PromiseRejectedResult | undefined; - if (rejectedResult) { - throw rejectedResult.reason; - } - - sessionsDb.deleteSession(safeSessionId); -} diff --git a/server/src/modules/sessions/sessions.watcher.ts b/server/src/modules/sessions/sessions.watcher.ts deleted file mode 100644 index 683a248d..00000000 --- a/server/src/modules/sessions/sessions.watcher.ts +++ /dev/null @@ -1,184 +0,0 @@ -import chokidar from "chokidar"; -import path from "path"; -import os from "os"; -import { promises as fsPromises } from "fs"; -import { logger } from "@/shared/utils/logger.js"; -import { processSessions } from "@/modules/sessions/sessions.service.js"; -import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-processor.js"; -import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-processor.js"; -import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-processor.js"; -import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-processor.js"; -import { sessionsDb } from "@/shared/database/repositories/sessions.db.js"; -import { LLMProvider } from "@/shared/types/app.js"; - -let projectsWatchers: any[] = []; - -// File system watchers for provider project/session folders -const PROVIDER_WATCH_PATHS: { provider: LLMProvider; rootPath: string }[] = [ - { - provider: "claude", - rootPath: path.join(os.homedir(), ".claude", "projects"), - }, - { - provider: "cursor", - rootPath: path.join(os.homedir(), ".cursor", "chats") - }, - { - provider: "codex", - rootPath: path.join(os.homedir(), ".codex", "sessions"), - }, - { - provider: "gemini", - rootPath: path.join(os.homedir(), ".gemini", "sessions"), - }, - { - provider: "gemini", - rootPath: path.join(os.homedir(), ".gemini", "tmp"), - }, -]; - -const WATCHER_IGNORED_PATTERNS = [ - "**/node_modules/**", - "**/.git/**", - "**/dist/**", - "**/build/**", - "**/*.tmp", - "**/*.swp", - "**/.DS_Store", -]; - -type EventType = "add" | "change"; - -const onUpdate = async ( - eventType: EventType, - filePath: string, - provider: LLMProvider, -) => { - try { - console.log("[eventType] detected: ", eventType, " filePath: ", filePath, " provider: ", provider); - - switch (eventType) { - case "add": - case "change": { - let sessionId: string | null = null; - let workspacePath: string | null = null; - let sessionName = `Untitled ${provider} Session`; - - switch (provider) { - case "claude": { - const result = await processClaudeSessionFile(filePath); - if (result) { - sessionId = result.sessionId; - workspacePath = result.workspacePath; - sessionName = result.sessionName || sessionName; - } - break; - } - case "codex": { - const result = await processCodexSessionFile(filePath); - if (result) { - sessionId = result.sessionId; - workspacePath = result.workspacePath; - sessionName = result.sessionName || sessionName; - } - break; - } - case "gemini": { - const result = await processGeminiSessionFile(filePath); - if (result) { - sessionId = result.sessionId; - workspacePath = result.workspacePath; - sessionName = result.sessionName || sessionName; - } - break; - } - case "cursor": { - const result = await processCursorSessionFile(filePath); - if (result) { - sessionId = result.sessionId; - workspacePath = result.workspacePath; - sessionName = result.sessionName || sessionName; - } - break; - } - } - - if (sessionId && workspacePath) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsPromises.stat(filePath); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - - sessionsDb.createSession( - sessionId, - provider, - workspacePath, - sessionName, - createdAt, - updatedAt, - filePath, - ); - } - break; - } - } - } catch (error: any) { - logger.error( - `[ERROR] Failed to handle ${provider} file change for ${filePath}:`, - error, - ); - } -}; - -// Setup file system watchers for Claude, Cursor, and Codex project/session folders -export async function initializeWatcher() { - logger.info("Setting up project watchers for providers..."); - - await processSessions(); - - for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { - try { - // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. - // Ensure provider folders exist before creating the watcher so watching stays active. - await fsPromises.mkdir(rootPath, { recursive: true }); - - logger.info(`Setting up watcher for ${provider} at: ${rootPath}`); - - const watcher = chokidar.watch(rootPath, { - ignored: WATCHER_IGNORED_PATTERNS, - persistent: true, - ignoreInitial: true, // Don't fire events for existing files on startup - followSymlinks: false, - depth: 6, // Reasonable depth limit - usePolling: true, // Use polling to fix Windows fs.watch buffering/batching issues. It now stops relying on the OS's native file-system events and instead manually checks the files for changes at a set interval. - interval: 2000, // Poll every 2000ms - binaryInterval: 6000, // We set a high amount because checking large binary files for changes using polling is much more CPU-intensive than checking small text files. - // Removed awaitWriteFinish to prevent delays when LLM streams to the file - - }); - - // Set up event listeners - watcher - .on("add", (filePath) => onUpdate("add", filePath, provider)) - .on("change", (filePath) => - onUpdate("change", filePath, provider), - ) - .on("error", (error: any) => { - logger.error(`[ERROR] ${provider} watcher error: ${error.message}`); - }) - .on("ready", () => { }); - - projectsWatchers.push(watcher); - } catch (error: any) { - logger.error( - `[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, - error, - ); - } - } -} diff --git a/server/src/modules/sidebar/sidebar.routes.ts b/server/src/modules/sidebar/sidebar.routes.ts deleted file mode 100644 index e326a870..00000000 --- a/server/src/modules/sidebar/sidebar.routes.ts +++ /dev/null @@ -1,171 +0,0 @@ -import express, { type Request, type Response } from 'express'; - -import { authenticateToken } from '@/modules/auth/auth.middleware.js'; -import { - deleteSessionById, - deleteWorkspaceByPath, - getWorkspaceSessionsCollection, - updateSessionNameById, - updateWorkspaceNameByPath, - updateWorkspaceStarByPath, -} from '@/modules/sidebar/sidebar.service.js'; - -const router = express.Router(); - -const getTrimmedString = (value: unknown): string => { - if (typeof value !== 'string') { - return ''; - } - - return value.trim(); -}; - -const getWorkspacePathFromBody = (req: Request): string => getTrimmedString(req.body?.workspacePath); - -router.get( - '/api/sidebar/get-workspaces-sessions', - authenticateToken, - async (_req: Request, res: Response): Promise => { - try { - const workspaces = getWorkspaceSessionsCollection(); - res.json({ workspaces }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to fetch workspaces'; - res.status(500).json({ error: message }); - } - }, -); - -router.put( - '/api/sidebar/update-workspace-star', - authenticateToken, - async (req: Request, res: Response): Promise => { - try { - const workspacePath = getWorkspacePathFromBody(req); - if (!workspacePath) { - res.status(400).json({ error: 'workspacePath is required' }); - return; - } - - const isStarred = updateWorkspaceStarByPath(workspacePath); - res.json({ success: true, workspacePath, isStarred }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update workspace star'; - const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; - res.status(statusCode).json({ error: message }); - } - }, -); - -router.put( - '/api/sidebar/update-workspace-custom-name', - authenticateToken, - async (req: Request, res: Response): Promise => { - try { - const workspacePath = getWorkspacePathFromBody(req); - if (!workspacePath) { - res.status(400).json({ error: 'workspacePath is required' }); - return; - } - - const customWorkspaceName = getTrimmedString(req.body?.workspaceCustomName); - updateWorkspaceNameByPath(workspacePath, customWorkspaceName || null); - - res.json({ success: true, workspacePath, workspaceCustomName: customWorkspaceName || null }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update workspace name'; - res.status(500).json({ error: message }); - } - }, -); - -router.put( - '/api/sidebar/update-session-custom-name', - authenticateToken, - async (req: Request, res: Response): Promise => { - try { - const sessionId = getTrimmedString(req.body?.sessionId); - const sessionCustomName = getTrimmedString(req.body?.sessionCustomName); - - if (!sessionId) { - res.status(400).json({ error: 'sessionId is required' }); - return; - } - - if (!sessionCustomName) { - res.status(400).json({ error: 'sessionCustomName is required' }); - return; - } - - if (sessionCustomName.length > 500) { - res - .status(400) - .json({ error: 'sessionCustomName must not exceed 500 characters' }); - return; - } - - updateSessionNameById(sessionId, sessionCustomName); - res.json({ success: true, sessionId, sessionCustomName }); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to update session name'; - const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; - res.status(statusCode).json({ error: message }); - } - }, -); - -router.delete( - '/api/sidebar/delete-workspace', - authenticateToken, - async (req: Request, res: Response): Promise => { - try { - const workspacePath = getWorkspacePathFromBody(req); - if (!workspacePath) { - res.status(400).json({ error: 'workspacePath is required' }); - return; - } - - const result = await deleteWorkspaceByPath(workspacePath); - res.json({ - success: true, - workspacePath, - ...result, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete workspace'; - res.status(500).json({ error: message }); - } - }, -); - -router.delete( - '/api/sidebar/delete-session', - authenticateToken, - async (req: Request, res: Response): Promise => { - try { - const sessionId = getTrimmedString(req.body?.sessionId); - if (!sessionId) { - res.status(400).json({ error: 'sessionId is required' }); - return; - } - - const result = await deleteSessionById(sessionId); - if (!result.deleted) { - res.status(404).json({ error: 'Session not found' }); - return; - } - - res.json({ - success: true, - sessionId, - ...result, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete session'; - res.status(500).json({ error: message }); - } - }, -); - -export default router; diff --git a/server/src/modules/sidebar/sidebar.service.ts b/server/src/modules/sidebar/sidebar.service.ts deleted file mode 100644 index ac2d2be9..00000000 --- a/server/src/modules/sidebar/sidebar.service.ts +++ /dev/null @@ -1,247 +0,0 @@ -import path from 'node:path'; - -import { deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js'; -import { deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js'; -import { deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js'; -import { deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; -import type { SessionsRow } from '@/shared/database/types.js'; - -export type SidebarSessionRecord = { - sessionId: string; - id: string; - provider: SessionsRow['provider']; - customName: string | null; - summary: string; - workspacePath: string; - createdAt: string | null; - updatedAt: string | null; - lastActivity: string | null; -}; - -export type SidebarWorkspaceRecord = { - workspaceOriginalPath: string; - workspaceCustomName: string | null; - workspaceDisplayName: string; - isStarred: boolean; - lastActivity: string | null; - sessions: SidebarSessionRecord[]; -}; - -export type DeleteSessionResult = { - deleted: boolean; - jsonlDeleted: boolean; -}; - -export type DeleteWorkspaceResult = { - deletedWorkspace: boolean; - deletedSessionCount: number; - jsonlDeletedCount: number; - failedSessionFileDeletes: string[]; -}; - -type SessionDeletionTarget = Pick; - -const parseTimestamp = (timestamp: string | null | undefined): number => { - if (!timestamp) { - return 0; - } - - // SQLite CURRENT_TIMESTAMP is UTC but stored without timezone ("YYYY-MM-DD HH:MM:SS"). - // Normalize this format so parsing is always timezone-correct. - const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; - const normalizedTimestamp = sqliteUtcPattern.test(timestamp) - ? `${timestamp.replace(' ', 'T')}Z` - : timestamp; - - const parsed = new Date(normalizedTimestamp).getTime(); - return Number.isFinite(parsed) ? parsed : 0; -}; - -const toSidebarSessionRecord = (session: SessionsRow): SidebarSessionRecord => { - const lastActivity = session.updated_at || session.created_at || null; - - return { - sessionId: session.session_id, - id: session.session_id, - provider: session.provider, - customName: session.custom_name, - summary: session.custom_name || 'Untitled Session', - workspacePath: session.workspace_path, - createdAt: session.created_at || null, - updatedAt: session.updated_at || null, - lastActivity, - }; -}; - -const sortSessionsByLastActivity = (sessions: SidebarSessionRecord[]): SidebarSessionRecord[] => - [...sessions].sort((left, right) => { - const timestampDifference = - parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); - - if (timestampDifference !== 0) { - return timestampDifference; - } - - return right.sessionId.localeCompare(left.sessionId); - }); - -const sortWorkspacesByLastActivity = ( - workspaces: SidebarWorkspaceRecord[], -): SidebarWorkspaceRecord[] => - [...workspaces].sort((left, right) => { - const timestampDifference = - parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); - - if (timestampDifference !== 0) { - return timestampDifference; - } - - return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName); - }); - -const deleteSessionFileByProvider = async ( - session: SessionDeletionTarget, -): Promise => { - switch (session.provider) { - case 'claude': - return deleteClaudeSession(session.session_id, session.workspace_path); - case 'codex': - return deleteCodexSession(session.session_id, session.created_at); - case 'cursor': - return deleteCursorSession(session.session_id, session.workspace_path); - case 'gemini': - return deleteGeminiSession(session.session_id); - default: - return false; - } -}; - -export const getWorkspaceSessionsCollection = (): SidebarWorkspaceRecord[] => { - const workspaceRows = workspaceOriginalPathsDb.getWorkspacePaths(); - const sessionRows = sessionsDb.getAllSessions(); - const sessionsByWorkspace = new Map(); - - // Build grouped sessions once to keep the response shape deterministic. - for (const sessionRow of sessionRows) { - const existing = sessionsByWorkspace.get(sessionRow.workspace_path) || []; - existing.push(toSidebarSessionRecord(sessionRow)); - sessionsByWorkspace.set(sessionRow.workspace_path, existing); - } - - const workspaceRecords = workspaceRows.map((workspaceRow) => { - const sessions = sortSessionsByLastActivity( - sessionsByWorkspace.get(workspaceRow.workspace_path) || [], - ); - const lastActivity = sessions[0]?.lastActivity || null; - - return { - workspaceOriginalPath: workspaceRow.workspace_path, - workspaceCustomName: workspaceRow.custom_workspace_name, - workspaceDisplayName: - workspaceRow.custom_workspace_name || - path.basename(workspaceRow.workspace_path) || - workspaceRow.workspace_path, - isStarred: workspaceRow.isStarred === 1, - lastActivity, - sessions, - }; - }); - - return sortWorkspacesByLastActivity(workspaceRecords); -}; - -export const updateWorkspaceStarByPath = (workspacePath: string): boolean => { - const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath); - if (!workspaceRow) { - throw new Error('Workspace not found'); - } - - const nextIsStarred = workspaceRow.isStarred !== 1; - workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred); - - return nextIsStarred; -}; - -export const updateWorkspaceNameByPath = ( - workspacePath: string, - workspaceCustomName: string | null, -): void => { - workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName); -}; - -export const updateSessionNameById = ( - sessionId: string, - sessionCustomName: string, -): void => { - const sessionMetadata = sessionsDb.getSessionById(sessionId); - if (!sessionMetadata) { - throw new Error('Session not found'); - } - - sessionsDb.updateSessionCustomName(sessionId, sessionCustomName); -}; - -export const deleteSessionById = async ( - sessionId: string, -): Promise => { - const sessionMetadata = sessionsDb.getSessionById(sessionId); - if (!sessionMetadata) { - return { - deleted: false, - jsonlDeleted: false, - }; - } - - const jsonlDeleted = await deleteSessionFileByProvider({ - session_id: sessionMetadata.session_id, - provider: sessionMetadata.provider, - workspace_path: sessionMetadata.workspace_path, - created_at: sessionMetadata.created_at, - }); - - sessionsDb.deleteSession(sessionId); - - return { - deleted: true, - jsonlDeleted, - }; -}; - -export const deleteWorkspaceByPath = async ( - workspacePath: string, -): Promise => { - const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath); - const failedSessionFileDeletes: string[] = []; - let jsonlDeletedCount = 0; - - // Remove all session files first, then clean up DB rows. - for (const sessionRow of sessionRows) { - try { - const deleted = await deleteSessionFileByProvider({ - session_id: sessionRow.session_id, - provider: sessionRow.provider, - workspace_path: sessionRow.workspace_path, - created_at: sessionRow.created_at, - }); - - if (deleted) { - jsonlDeletedCount += 1; - } - } catch { - failedSessionFileDeletes.push(sessionRow.session_id); - } finally { - sessionsDb.deleteSession(sessionRow.session_id); - } - } - - workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath); - - return { - deletedWorkspace: true, - deletedSessionCount: sessionRows.length, - jsonlDeletedCount, - failedSessionFileDeletes, - }; -}; diff --git a/server/src/modules/workspaces/workspaces.routes.ts b/server/src/modules/workspaces/workspaces.routes.ts new file mode 100644 index 00000000..e348fd77 --- /dev/null +++ b/server/src/modules/workspaces/workspaces.routes.ts @@ -0,0 +1,97 @@ +import express, { type NextFunction, type Request, type Response } from 'express'; + +import { asyncHandler } from '@/shared/http/async-handler.js'; +import { AppError } from '@/shared/utils/app-error.js'; +import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js'; +import { logger } from '@/shared/utils/logger.js'; +import { workspaceService } from '@/modules/workspaces/workspaces.service.js'; + +const router = express.Router(); + +const getTrimmedString = (value: unknown): string => { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +}; + +const parseWorkspacePathFromBody = (req: Request): string => { + const body = req.body as Record | undefined; + const workspacePath = getTrimmedString(body?.workspacePath); + if (!workspacePath) { + throw new AppError('workspacePath is required.', { + code: 'WORKSPACE_PATH_REQUIRED', + statusCode: 400, + }); + } + + return workspacePath; +}; + +const parseWorkspaceCustomNameFromBody = (req: Request): string | null => { + const body = req.body as Record | undefined; + const customName = getTrimmedString(body?.workspaceCustomName); + return customName || null; +}; + +router.get( + '/', + asyncHandler(async (_req: Request, res: Response) => { + const workspaces = workspaceService.listWorkspaces(); + res.json(createApiSuccessResponse({ workspaces })); + }), +); + +router.patch( + '/star', + asyncHandler(async (req: Request, res: Response) => { + const workspacePath = parseWorkspacePathFromBody(req); + const isStarred = workspaceService.toggleWorkspaceStar(workspacePath); + res.json(createApiSuccessResponse({ workspacePath, isStarred })); + }), +); + +router.patch( + '/name', + asyncHandler(async (req: Request, res: Response) => { + const workspacePath = parseWorkspacePathFromBody(req); + const workspaceCustomName = parseWorkspaceCustomNameFromBody(req); + workspaceService.updateWorkspaceCustomName(workspacePath, workspaceCustomName); + res.json(createApiSuccessResponse({ workspacePath, workspaceCustomName })); + }), +); + +router.delete( + '/', + asyncHandler(async (req: Request, res: Response) => { + const workspacePath = parseWorkspacePathFromBody(req); + const result = await workspaceService.deleteWorkspace(workspacePath); + res.json(createApiSuccessResponse(result)); + }), +); + +/** + * Normalizes route-level failures to a consistent JSON API shape. + */ +router.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (res.headersSent) { + return; + } + + if (error instanceof AppError) { + res + .status(error.statusCode) + .json(createApiErrorResponse(error.code, error.message, undefined, error.details)); + return; + } + + const message = error instanceof Error ? error.message : 'Unexpected workspaces route failure.'; + logger.error(message, { + module: 'workspaces.routes', + }); + + res.status(500).json(createApiErrorResponse('INTERNAL_ERROR', message)); +}); + +export default router; diff --git a/server/src/modules/workspaces/workspaces.service.ts b/server/src/modules/workspaces/workspaces.service.ts new file mode 100644 index 00000000..2120a818 --- /dev/null +++ b/server/src/modules/workspaces/workspaces.service.ts @@ -0,0 +1,188 @@ +import path from 'node:path'; + +import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; +import type { SessionsRow } from '@/shared/database/types.js'; +import { AppError } from '@/shared/utils/app-error.js'; + +export type WorkspaceSessionRecord = { + sessionId: string; + id: string; + provider: SessionsRow['provider']; + customName: string | null; + summary: string; + workspacePath: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; +}; + +export type WorkspaceRecord = { + workspaceOriginalPath: string; + workspaceCustomName: string | null; + workspaceDisplayName: string; + isStarred: boolean; + lastActivity: string | null; + sessions: WorkspaceSessionRecord[]; +}; + +const parseTimestamp = (timestamp: string | null | undefined): number => { + if (!timestamp) { + return 0; + } + + // SQLite CURRENT_TIMESTAMP is UTC but stored without timezone ("YYYY-MM-DD HH:MM:SS"). + // Normalize this format so parsing is always timezone-correct. + const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + const normalizedTimestamp = sqliteUtcPattern.test(timestamp) + ? `${timestamp.replace(' ', 'T')}Z` + : timestamp; + + const parsed = new Date(normalizedTimestamp).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const toWorkspaceSessionRecord = (session: SessionsRow): WorkspaceSessionRecord => { + const lastActivity = session.updated_at || session.created_at || null; + + return { + sessionId: session.session_id, + id: session.session_id, + provider: session.provider, + customName: session.custom_name, + summary: session.custom_name || 'Untitled Session', + workspacePath: session.workspace_path, + createdAt: session.created_at || null, + updatedAt: session.updated_at || null, + lastActivity, + }; +}; + +const sortSessionsByLastActivity = (sessions: WorkspaceSessionRecord[]): WorkspaceSessionRecord[] => + [...sessions].sort((left, right) => { + const timestampDifference = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDifference !== 0) { + return timestampDifference; + } + + return right.sessionId.localeCompare(left.sessionId); + }); + +const sortWorkspacesByLastActivity = ( + workspaces: WorkspaceRecord[], +): WorkspaceRecord[] => + [...workspaces].sort((left, right) => { + const timestampDifference = + parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity); + + if (timestampDifference !== 0) { + return timestampDifference; + } + + return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName); + }); + +/** + * Groups indexed sessions by workspace and returns a deterministic catalog shape. + */ +const buildWorkspaceSessionCollection = (): WorkspaceRecord[] => { + const workspaceRows = workspaceOriginalPathsDb.getWorkspacePaths(); + const sessionRows = sessionsDb.getAllSessions(); + const sessionsByWorkspace = new Map(); + + // Build grouped sessions once to keep the response shape deterministic. + for (const sessionRow of sessionRows) { + const existing = sessionsByWorkspace.get(sessionRow.workspace_path) || []; + existing.push(toWorkspaceSessionRecord(sessionRow)); + sessionsByWorkspace.set(sessionRow.workspace_path, existing); + } + + const workspaceRecords = workspaceRows.map((workspaceRow) => { + const sessions = sortSessionsByLastActivity( + sessionsByWorkspace.get(workspaceRow.workspace_path) || [], + ); + const lastActivity = sessions[0]?.lastActivity || null; + + return { + workspaceOriginalPath: workspaceRow.workspace_path, + workspaceCustomName: workspaceRow.custom_workspace_name, + workspaceDisplayName: + workspaceRow.custom_workspace_name || + path.basename(workspaceRow.workspace_path) || + workspaceRow.workspace_path, + isStarred: workspaceRow.isStarred === 1, + lastActivity, + sessions, + }; + }); + + return sortWorkspacesByLastActivity(workspaceRecords); +}; + +/** + * Workspace catalog facade consumed by HTTP routes. + */ +export const workspaceService = { + listWorkspaces(): WorkspaceRecord[] { + return buildWorkspaceSessionCollection(); + }, + + toggleWorkspaceStar(workspacePath: string): boolean { + const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath); + if (!workspaceRow) { + throw new AppError('Workspace not found.', { + code: 'WORKSPACE_NOT_FOUND', + statusCode: 404, + }); + } + + const nextIsStarred = workspaceRow.isStarred !== 1; + workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred); + + return nextIsStarred; + }, + + updateWorkspaceCustomName(workspacePath: string, workspaceCustomName: string | null): void { + workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName); + }, + + async deleteWorkspace(workspacePath: string): Promise<{ + workspacePath: string; + deletedWorkspace: boolean; + deletedSessionCount: number; + jsonlDeletedCount: number; + failedSessionFileDeletes: string[]; + }> { + const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath); + const failedSessionFileDeletes: string[] = []; + let jsonlDeletedCount = 0; + + // Remove all session files first, then clean up DB rows. + for (const sessionRow of sessionRows) { + try { + const deletionResult = await llmSessionsService.deleteSessionArtifacts( + sessionRow.session_id, + ); + + if (deletionResult.deletedFromDisk) { + jsonlDeletedCount += 1; + } + } catch { + failedSessionFileDeletes.push(sessionRow.session_id); + } + } + + workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath); + + return { + workspacePath, + deletedWorkspace: true, + deletedSessionCount: sessionRows.length, + jsonlDeletedCount, + failedSessionFileDeletes, + }; + }, +}; diff --git a/server/src/runner.ts b/server/src/runner.ts index 6435a059..c2be241e 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -8,7 +8,7 @@ import { dirname } from 'path'; import { fileURLToPath } from 'url'; import { initializeDatabase } from '@/shared/database/init-db.js'; -import { initializeWatcher } from '@/modules/sessions/sessions.watcher.js'; +import { initializeWatcher } from '@/modules/llm/sessions.watcher.js'; import { configureWebPush } from '@/modules/push-sub/push-sub.services.js'; import { getConnectableHost } from '@/shared/utils/networkHosts.js'; import { logger } from '@/shared/utils/logger.js'; @@ -61,10 +61,11 @@ const [ geminiRoutes, pluginsRoutes, messagesRoutes, - sidebarRoutes, + conversationsRoutes, + workspacesRoutes, projectsInlineRoutes, filesRoutes, - sessionsInlineRoutes, + llmRoutes, ] = await Promise.all([ importRoute('./modules/health/health.routes.js'), importRoute('./modules/system/system.routes.js'), @@ -86,10 +87,11 @@ const [ importRoute('./modules/gemini/gemini.routes.js'), importRoute('./modules/plugins/plugins.routes.js'), importRoute('./modules/messages/messages.routes.js'), - importRoute('./modules/sidebar/sidebar.routes.js'), + importRoute('./modules/conversations/conversations.routes.js'), + importRoute('./modules/workspaces/workspaces.routes.js'), importRoute('./modules/projects/projects.inline.routes.js'), importRoute('./modules/files/files.routes.js'), - importRoute('./modules/sessions/sessions.inline.routes.js'), + importRoute('./modules/llm/llm.routes.js'), ]); // ---------- MIDDLEWARES ---------------- @@ -176,8 +178,14 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes); // Unified session messages route (protected) app.use('/api/sessions', authenticateToken, messagesRoutes); -// Refactored sidebar routes (protected) -app.use(sidebarRoutes); +// Conversation search routes (protected) +app.use('/api/conversations', authenticateToken, conversationsRoutes); + +// Workspace catalog routes (protected) +app.use('/api/workspaces', authenticateToken, workspacesRoutes); + +// Unified LLM provider API routes (protected) +app.use('/api/llm', authenticateToken, llmRoutes); // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); @@ -186,7 +194,6 @@ app.use('/api/agent', agentRoutes); app.use(systemRoutes); app.use(projectsInlineRoutes); app.use(filesRoutes); -app.use(sessionsInlineRoutes); // This matches files found in the root public folder (like api-docs.html when we run `/api-docs.html`). // If the file is found, it's automatically sent. If it is not, it passes it to the next route checker. diff --git a/src/components/refactored/sidebar/data/workspacesApi.ts b/src/components/refactored/sidebar/data/workspacesApi.ts index be08c49e..9957f060 100644 --- a/src/components/refactored/sidebar/data/workspacesApi.ts +++ b/src/components/refactored/sidebar/data/workspacesApi.ts @@ -2,12 +2,10 @@ import type { WorkspaceRecord } from '@/components/refactored/sidebar/types'; import { authenticatedFetch } from '@/utils/api'; const SIDEBAR_ENDPOINTS = { - getWorkspaceSessions: '/api/sidebar/get-workspaces-sessions', - updateWorkspaceStar: '/api/sidebar/update-workspace-star', - updateWorkspaceCustomName: '/api/sidebar/update-workspace-custom-name', - updateSessionCustomName: '/api/sidebar/update-session-custom-name', - deleteWorkspace: '/api/sidebar/delete-workspace', - deleteSession: '/api/sidebar/delete-session', + getWorkspaceSessions: '/api/workspaces', + updateWorkspaceStar: '/api/workspaces/star', + updateWorkspaceCustomName: '/api/workspaces/name', + deleteWorkspace: '/api/workspaces', } as const; const parseJsonSafely = async (response: Response): Promise => { @@ -28,40 +26,62 @@ const getErrorMessage = (fallbackMessage: string, payload: unknown): string => { return (payload as { error: string }).error; } + if ( + payload && + typeof payload === 'object' && + 'error' in payload && + typeof (payload as { error?: { message?: unknown } }).error?.message === 'string' + ) { + return (payload as { error: { message: string } }).error.message; + } + return fallbackMessage; }; export const getWorkspaceSessions = async (): Promise => { const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.getWorkspaceSessions); - const payload = await parseJsonSafely<{ workspaces?: WorkspaceRecord[]; error?: string }>(response); + const payload = await parseJsonSafely<{ + success?: boolean; + data?: { workspaces?: WorkspaceRecord[] }; + error?: { message?: string }; + }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to fetch workspaces', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to fetch workspaces', payload), + ); } - return payload?.workspaces || []; + return payload?.data?.workspaces || []; }; export const updateWorkspaceStar = async ( workspacePath: string, ): Promise<{ workspacePath: string; isStarred: boolean }> => { const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceStar, { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ workspacePath }), }); const payload = await parseJsonSafely<{ - workspacePath?: string; - isStarred?: boolean; - error?: string; + success?: boolean; + data?: { + workspacePath?: string; + isStarred?: boolean; + }; + error?: { message?: string }; }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to update workspace star', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to update workspace star', payload), + ); } return { - workspacePath: payload?.workspacePath || workspacePath, - isStarred: Boolean(payload?.isStarred), + workspacePath: payload?.data?.workspacePath || workspacePath, + isStarred: Boolean(payload?.data?.isStarred), }; }; @@ -70,13 +90,16 @@ export const updateWorkspaceCustomName = async ( workspaceCustomName: string | null, ): Promise => { const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceCustomName, { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ workspacePath, workspaceCustomName }), }); - const payload = await parseJsonSafely<{ error?: string }>(response); + const payload = await parseJsonSafely<{ error?: { message?: string } }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to update workspace name', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to update workspace name', payload), + ); } }; @@ -85,10 +108,13 @@ export const deleteWorkspaceByPath = async (workspacePath: string): Promise(response); + const payload = await parseJsonSafely<{ error?: { message?: string } }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to delete workspace', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to delete workspace', payload), + ); } }; @@ -96,25 +122,30 @@ export const updateSessionCustomName = async ( sessionId: string, sessionCustomName: string, ): Promise => { - const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateSessionCustomName, { + const response = await authenticatedFetch(`/api/llm/sessions/${encodeURIComponent(sessionId)}/rename`, { method: 'PUT', - body: JSON.stringify({ sessionId, sessionCustomName }), + body: JSON.stringify({ summary: sessionCustomName }), }); - const payload = await parseJsonSafely<{ error?: string }>(response); + const payload = await parseJsonSafely<{ error?: { message?: string } }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to update session name', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to update session name', payload), + ); } }; export const deleteSessionById = async (sessionId: string): Promise => { - const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteSession, { + const response = await authenticatedFetch(`/api/llm/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', - body: JSON.stringify({ sessionId }), }); - const payload = await parseJsonSafely<{ error?: string }>(response); + const payload = await parseJsonSafely<{ error?: { message?: string } }>(response); if (!response.ok) { - throw new Error(getErrorMessage('Failed to delete session', payload)); + throw new Error( + payload?.error?.message || + getErrorMessage('Failed to delete session', payload), + ); } }; diff --git a/src/components/settings/view/modals/ClaudeMcpFormModal.tsx b/src/components/settings/view/modals/ClaudeMcpFormModal.tsx index 0eb56f10..77eb9661 100644 --- a/src/components/settings/view/modals/ClaudeMcpFormModal.tsx +++ b/src/components/settings/view/modals/ClaudeMcpFormModal.tsx @@ -94,15 +94,17 @@ export default function ClaudeMcpFormModal({ setProjectsError(null); try { - const response = await authenticatedFetch('/api/sidebar/get-workspaces-sessions'); + const response = await authenticatedFetch('/api/workspaces'); const data = (await response.json()) as unknown; if (!response.ok) { throw new Error('Failed to fetch projects'); } - const rawProjects = Array.isArray((data as { workspaces?: unknown }).workspaces) - ? (data as { workspaces: ProjectApiRecord[] }).workspaces + const rawProjects = Array.isArray( + (data as { data?: { workspaces?: unknown } }).data?.workspaces, + ) + ? ((data as { data: { workspaces: ProjectApiRecord[] } }).data.workspaces) : []; if (!cancelled) {