@jait/gateway 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/jait.mjs +144 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/db/connection.d.ts +37 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +85 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +4 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrations.d.ts +24 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +312 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema.d.ts +2253 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +195 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/foundation.d.ts +26 -0
- package/dist/foundation.d.ts.map +1 -0
- package/dist/foundation.js +15 -0
- package/dist/foundation.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +413 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/uuidv7.d.ts +10 -0
- package/dist/lib/uuidv7.d.ts.map +1 -0
- package/dist/lib/uuidv7.js +33 -0
- package/dist/lib/uuidv7.js.map +1 -0
- package/dist/memory/contracts.d.ts +42 -0
- package/dist/memory/contracts.d.ts.map +1 -0
- package/dist/memory/contracts.js +2 -0
- package/dist/memory/contracts.js.map +1 -0
- package/dist/memory/embeddings.d.ts +4 -0
- package/dist/memory/embeddings.d.ts.map +1 -0
- package/dist/memory/embeddings.js +26 -0
- package/dist/memory/embeddings.js.map +1 -0
- package/dist/memory/service.d.ts +17 -0
- package/dist/memory/service.d.ts.map +1 -0
- package/dist/memory/service.js +82 -0
- package/dist/memory/service.js.map +1 -0
- package/dist/memory/sqlite-backend.d.ts +11 -0
- package/dist/memory/sqlite-backend.d.ts.map +1 -0
- package/dist/memory/sqlite-backend.js +68 -0
- package/dist/memory/sqlite-backend.js.map +1 -0
- package/dist/plugins/contracts.d.ts +11 -0
- package/dist/plugins/contracts.d.ts.map +1 -0
- package/dist/plugins/contracts.js +2 -0
- package/dist/plugins/contracts.js.map +1 -0
- package/dist/providers/claude-code-provider.d.ts +39 -0
- package/dist/providers/claude-code-provider.d.ts.map +1 -0
- package/dist/providers/claude-code-provider.js +322 -0
- package/dist/providers/claude-code-provider.js.map +1 -0
- package/dist/providers/codex-provider.d.ts +51 -0
- package/dist/providers/codex-provider.d.ts.map +1 -0
- package/dist/providers/codex-provider.js +826 -0
- package/dist/providers/codex-provider.js.map +1 -0
- package/dist/providers/contracts.d.ts +167 -0
- package/dist/providers/contracts.d.ts.map +1 -0
- package/dist/providers/contracts.js +13 -0
- package/dist/providers/contracts.js.map +1 -0
- package/dist/providers/index.d.ts +6 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/jait-provider.d.ts +23 -0
- package/dist/providers/jait-provider.d.ts.map +1 -0
- package/dist/providers/jait-provider.js +67 -0
- package/dist/providers/jait-provider.js.map +1 -0
- package/dist/providers/registry.d.ts +39 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +64 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/pty-broker-client.d.ts +46 -0
- package/dist/pty-broker-client.d.ts.map +1 -0
- package/dist/pty-broker-client.js +142 -0
- package/dist/pty-broker-client.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +236 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +32 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +1503 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/consent.d.ts +10 -0
- package/dist/routes/consent.d.ts.map +1 -0
- package/dist/routes/consent.js +127 -0
- package/dist/routes/consent.js.map +1 -0
- package/dist/routes/filesystem.d.ts +14 -0
- package/dist/routes/filesystem.d.ts.map +1 -0
- package/dist/routes/filesystem.js +152 -0
- package/dist/routes/filesystem.js.map +1 -0
- package/dist/routes/git.d.ts +17 -0
- package/dist/routes/git.d.ts.map +1 -0
- package/dist/routes/git.js +213 -0
- package/dist/routes/git.js.map +1 -0
- package/dist/routes/health.d.ts +7 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +21 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/hooks.d.ts +9 -0
- package/dist/routes/hooks.d.ts.map +1 -0
- package/dist/routes/hooks.js +22 -0
- package/dist/routes/hooks.js.map +1 -0
- package/dist/routes/jobs.d.ts +5 -0
- package/dist/routes/jobs.d.ts.map +1 -0
- package/dist/routes/jobs.js +333 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcp-server.d.ts +23 -0
- package/dist/routes/mcp-server.d.ts.map +1 -0
- package/dist/routes/mcp-server.js +177 -0
- package/dist/routes/mcp-server.js.map +1 -0
- package/dist/routes/mobile.d.ts +12 -0
- package/dist/routes/mobile.d.ts.map +1 -0
- package/dist/routes/mobile.js +64 -0
- package/dist/routes/mobile.js.map +1 -0
- package/dist/routes/network.d.ts +3 -0
- package/dist/routes/network.d.ts.map +1 -0
- package/dist/routes/network.js +367 -0
- package/dist/routes/network.js.map +1 -0
- package/dist/routes/repositories.d.ts +18 -0
- package/dist/routes/repositories.d.ts.map +1 -0
- package/dist/routes/repositories.js +90 -0
- package/dist/routes/repositories.js.map +1 -0
- package/dist/routes/screen-share.d.ts +17 -0
- package/dist/routes/screen-share.d.ts.map +1 -0
- package/dist/routes/screen-share.js +92 -0
- package/dist/routes/screen-share.js.map +1 -0
- package/dist/routes/sessions.d.ts +18 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +169 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/terminals.d.ts +15 -0
- package/dist/routes/terminals.d.ts.map +1 -0
- package/dist/routes/terminals.js +326 -0
- package/dist/routes/terminals.js.map +1 -0
- package/dist/routes/threads.d.ts +38 -0
- package/dist/routes/threads.d.ts.map +1 -0
- package/dist/routes/threads.js +488 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/trust.d.ts +9 -0
- package/dist/routes/trust.d.ts.map +1 -0
- package/dist/routes/trust.js +25 -0
- package/dist/routes/trust.js.map +1 -0
- package/dist/routes/voice.d.ts +5 -0
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +37 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/routes/workspace.d.ts +13 -0
- package/dist/routes/workspace.d.ts.map +1 -0
- package/dist/routes/workspace.js +275 -0
- package/dist/routes/workspace.js.map +1 -0
- package/dist/scheduler/contracts.d.ts +15 -0
- package/dist/scheduler/contracts.d.ts.map +1 -0
- package/dist/scheduler/contracts.js +2 -0
- package/dist/scheduler/contracts.js.map +1 -0
- package/dist/scheduler/hooks.d.ts +20 -0
- package/dist/scheduler/hooks.d.ts.map +1 -0
- package/dist/scheduler/hooks.js +78 -0
- package/dist/scheduler/hooks.js.map +1 -0
- package/dist/scheduler/service.d.ts +65 -0
- package/dist/scheduler/service.d.ts.map +1 -0
- package/dist/scheduler/service.js +188 -0
- package/dist/scheduler/service.js.map +1 -0
- package/dist/security/consent-executor.d.ts +48 -0
- package/dist/security/consent-executor.d.ts.map +1 -0
- package/dist/security/consent-executor.js +158 -0
- package/dist/security/consent-executor.js.map +1 -0
- package/dist/security/consent-manager.d.ts +105 -0
- package/dist/security/consent-manager.d.ts.map +1 -0
- package/dist/security/consent-manager.js +227 -0
- package/dist/security/consent-manager.js.map +1 -0
- package/dist/security/contracts.d.ts +31 -0
- package/dist/security/contracts.d.ts.map +1 -0
- package/dist/security/contracts.js +2 -0
- package/dist/security/contracts.js.map +1 -0
- package/dist/security/http-auth.d.ts +10 -0
- package/dist/security/http-auth.d.ts.map +1 -0
- package/dist/security/http-auth.js +48 -0
- package/dist/security/http-auth.js.map +1 -0
- package/dist/security/index.d.ts +10 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +9 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/path-guard.d.ts +40 -0
- package/dist/security/path-guard.d.ts.map +1 -0
- package/dist/security/path-guard.js +125 -0
- package/dist/security/path-guard.js.map +1 -0
- package/dist/security/sandbox-manager.d.ts +43 -0
- package/dist/security/sandbox-manager.d.ts.map +1 -0
- package/dist/security/sandbox-manager.js +110 -0
- package/dist/security/sandbox-manager.js.map +1 -0
- package/dist/security/ssrf-guard.d.ts +11 -0
- package/dist/security/ssrf-guard.d.ts.map +1 -0
- package/dist/security/ssrf-guard.js +59 -0
- package/dist/security/ssrf-guard.js.map +1 -0
- package/dist/security/tool-permissions.d.ts +61 -0
- package/dist/security/tool-permissions.d.ts.map +1 -0
- package/dist/security/tool-permissions.js +105 -0
- package/dist/security/tool-permissions.js.map +1 -0
- package/dist/security/tool-profiles.d.ts +23 -0
- package/dist/security/tool-profiles.d.ts.map +1 -0
- package/dist/security/tool-profiles.js +106 -0
- package/dist/security/tool-profiles.js.map +1 -0
- package/dist/security/trust-engine.d.ts +61 -0
- package/dist/security/trust-engine.d.ts.map +1 -0
- package/dist/security/trust-engine.js +192 -0
- package/dist/security/trust-engine.js.map +1 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +188 -0
- package/dist/server.js.map +1 -0
- package/dist/services/audit.d.ts +60 -0
- package/dist/services/audit.d.ts.map +1 -0
- package/dist/services/audit.js +58 -0
- package/dist/services/audit.js.map +1 -0
- package/dist/services/device-registry.d.ts +15 -0
- package/dist/services/device-registry.d.ts.map +1 -0
- package/dist/services/device-registry.js +32 -0
- package/dist/services/device-registry.js.map +1 -0
- package/dist/services/git.d.ts +168 -0
- package/dist/services/git.d.ts.map +1 -0
- package/dist/services/git.js +957 -0
- package/dist/services/git.js.map +1 -0
- package/dist/services/repositories.d.ts +32 -0
- package/dist/services/repositories.d.ts.map +1 -0
- package/dist/services/repositories.js +70 -0
- package/dist/services/repositories.js.map +1 -0
- package/dist/services/session-state.d.ts +20 -0
- package/dist/services/session-state.d.ts.map +1 -0
- package/dist/services/session-state.js +89 -0
- package/dist/services/session-state.js.map +1 -0
- package/dist/services/sessions.d.ts +68 -0
- package/dist/services/sessions.d.ts.map +1 -0
- package/dist/services/sessions.js +136 -0
- package/dist/services/sessions.js.map +1 -0
- package/dist/services/thread-title.d.ts +23 -0
- package/dist/services/thread-title.d.ts.map +1 -0
- package/dist/services/thread-title.js +141 -0
- package/dist/services/thread-title.js.map +1 -0
- package/dist/services/threads.d.ts +64 -0
- package/dist/services/threads.d.ts.map +1 -0
- package/dist/services/threads.js +202 -0
- package/dist/services/threads.js.map +1 -0
- package/dist/services/users.d.ts +39 -0
- package/dist/services/users.d.ts.map +1 -0
- package/dist/services/users.js +203 -0
- package/dist/services/users.js.map +1 -0
- package/dist/sessions/contracts.d.ts +14 -0
- package/dist/sessions/contracts.d.ts.map +1 -0
- package/dist/sessions/contracts.js +2 -0
- package/dist/sessions/contracts.js.map +1 -0
- package/dist/surfaces/browser.d.ts +65 -0
- package/dist/surfaces/browser.d.ts.map +1 -0
- package/dist/surfaces/browser.js +615 -0
- package/dist/surfaces/browser.js.map +1 -0
- package/dist/surfaces/contracts.d.ts +34 -0
- package/dist/surfaces/contracts.d.ts.map +1 -0
- package/dist/surfaces/contracts.js +2 -0
- package/dist/surfaces/contracts.js.map +1 -0
- package/dist/surfaces/filesystem.d.ts +76 -0
- package/dist/surfaces/filesystem.d.ts.map +1 -0
- package/dist/surfaces/filesystem.js +245 -0
- package/dist/surfaces/filesystem.js.map +1 -0
- package/dist/surfaces/index.d.ts +6 -0
- package/dist/surfaces/index.d.ts.map +1 -0
- package/dist/surfaces/index.js +5 -0
- package/dist/surfaces/index.js.map +1 -0
- package/dist/surfaces/registry.d.ts +24 -0
- package/dist/surfaces/registry.d.ts.map +1 -0
- package/dist/surfaces/registry.js +59 -0
- package/dist/surfaces/registry.js.map +1 -0
- package/dist/surfaces/terminal.d.ts +76 -0
- package/dist/surfaces/terminal.d.ts.map +1 -0
- package/dist/surfaces/terminal.js +271 -0
- package/dist/surfaces/terminal.js.map +1 -0
- package/dist/tools/agent-loop.d.ts +302 -0
- package/dist/tools/agent-loop.d.ts.map +1 -0
- package/dist/tools/agent-loop.js +918 -0
- package/dist/tools/agent-loop.js.map +1 -0
- package/dist/tools/agent-tools.d.ts +39 -0
- package/dist/tools/agent-tools.d.ts.map +1 -0
- package/dist/tools/agent-tools.js +263 -0
- package/dist/tools/agent-tools.js.map +1 -0
- package/dist/tools/browser-tools.d.ts +38 -0
- package/dist/tools/browser-tools.d.ts.map +1 -0
- package/dist/tools/browser-tools.js +725 -0
- package/dist/tools/browser-tools.js.map +1 -0
- package/dist/tools/chat-modes.d.ts +75 -0
- package/dist/tools/chat-modes.d.ts.map +1 -0
- package/dist/tools/chat-modes.js +228 -0
- package/dist/tools/chat-modes.js.map +1 -0
- package/dist/tools/contracts.d.ts +69 -0
- package/dist/tools/contracts.d.ts.map +1 -0
- package/dist/tools/contracts.js +2 -0
- package/dist/tools/contracts.js.map +1 -0
- package/dist/tools/core/agent.d.ts +31 -0
- package/dist/tools/core/agent.d.ts.map +1 -0
- package/dist/tools/core/agent.js +65 -0
- package/dist/tools/core/agent.js.map +1 -0
- package/dist/tools/core/edit.d.ts +30 -0
- package/dist/tools/core/edit.d.ts.map +1 -0
- package/dist/tools/core/edit.js +109 -0
- package/dist/tools/core/edit.js.map +1 -0
- package/dist/tools/core/execute.d.ts +36 -0
- package/dist/tools/core/execute.d.ts.map +1 -0
- package/dist/tools/core/execute.js +81 -0
- package/dist/tools/core/execute.js.map +1 -0
- package/dist/tools/core/get-fs.d.ts +32 -0
- package/dist/tools/core/get-fs.d.ts.map +1 -0
- package/dist/tools/core/get-fs.js +143 -0
- package/dist/tools/core/get-fs.js.map +1 -0
- package/dist/tools/core/index.d.ts +26 -0
- package/dist/tools/core/index.d.ts.map +1 -0
- package/dist/tools/core/index.js +26 -0
- package/dist/tools/core/index.js.map +1 -0
- package/dist/tools/core/jait.d.ts +60 -0
- package/dist/tools/core/jait.d.ts.map +1 -0
- package/dist/tools/core/jait.js +256 -0
- package/dist/tools/core/jait.js.map +1 -0
- package/dist/tools/core/read.d.ts +26 -0
- package/dist/tools/core/read.d.ts.map +1 -0
- package/dist/tools/core/read.js +118 -0
- package/dist/tools/core/read.js.map +1 -0
- package/dist/tools/core/search.d.ts +34 -0
- package/dist/tools/core/search.d.ts.map +1 -0
- package/dist/tools/core/search.js +187 -0
- package/dist/tools/core/search.js.map +1 -0
- package/dist/tools/core/todo.d.ts +38 -0
- package/dist/tools/core/todo.d.ts.map +1 -0
- package/dist/tools/core/todo.js +116 -0
- package/dist/tools/core/todo.js.map +1 -0
- package/dist/tools/core/web.d.ts +34 -0
- package/dist/tools/core/web.d.ts.map +1 -0
- package/dist/tools/core/web.js +120 -0
- package/dist/tools/core/web.js.map +1 -0
- package/dist/tools/cron-tools.d.ts +7 -0
- package/dist/tools/cron-tools.d.ts.map +1 -0
- package/dist/tools/cron-tools.js +116 -0
- package/dist/tools/cron-tools.js.map +1 -0
- package/dist/tools/file-tools.d.ts +32 -0
- package/dist/tools/file-tools.d.ts.map +1 -0
- package/dist/tools/file-tools.js +178 -0
- package/dist/tools/file-tools.js.map +1 -0
- package/dist/tools/gateway-tools.d.ts +15 -0
- package/dist/tools/gateway-tools.d.ts.map +1 -0
- package/dist/tools/gateway-tools.js +39 -0
- package/dist/tools/gateway-tools.js.map +1 -0
- package/dist/tools/index.d.ts +57 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +170 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/mcp-bridge.d.ts +111 -0
- package/dist/tools/mcp-bridge.d.ts.map +1 -0
- package/dist/tools/mcp-bridge.js +166 -0
- package/dist/tools/mcp-bridge.js.map +1 -0
- package/dist/tools/memory-tools.d.ts +19 -0
- package/dist/tools/memory-tools.d.ts.map +1 -0
- package/dist/tools/memory-tools.js +78 -0
- package/dist/tools/memory-tools.js.map +1 -0
- package/dist/tools/meta-tools.d.ts +25 -0
- package/dist/tools/meta-tools.d.ts.map +1 -0
- package/dist/tools/meta-tools.js +125 -0
- package/dist/tools/meta-tools.js.map +1 -0
- package/dist/tools/network-tools.d.ts +21 -0
- package/dist/tools/network-tools.d.ts.map +1 -0
- package/dist/tools/network-tools.js +189 -0
- package/dist/tools/network-tools.js.map +1 -0
- package/dist/tools/os-tools.d.ts +18 -0
- package/dist/tools/os-tools.d.ts.map +1 -0
- package/dist/tools/os-tools.js +210 -0
- package/dist/tools/os-tools.js.map +1 -0
- package/dist/tools/prompts/claude-prompt.d.ts +8 -0
- package/dist/tools/prompts/claude-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/claude-prompt.js +228 -0
- package/dist/tools/prompts/claude-prompt.js.map +1 -0
- package/dist/tools/prompts/default-openai-prompt.d.ts +8 -0
- package/dist/tools/prompts/default-openai-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/default-openai-prompt.js +67 -0
- package/dist/tools/prompts/default-openai-prompt.js.map +1 -0
- package/dist/tools/prompts/default-prompt.d.ts +7 -0
- package/dist/tools/prompts/default-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/default-prompt.js +50 -0
- package/dist/tools/prompts/default-prompt.js.map +1 -0
- package/dist/tools/prompts/gemini-prompt.d.ts +8 -0
- package/dist/tools/prompts/gemini-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/gemini-prompt.js +118 -0
- package/dist/tools/prompts/gemini-prompt.js.map +1 -0
- package/dist/tools/prompts/gpt5-codex-prompt.d.ts +8 -0
- package/dist/tools/prompts/gpt5-codex-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/gpt5-codex-prompt.js +72 -0
- package/dist/tools/prompts/gpt5-codex-prompt.js.map +1 -0
- package/dist/tools/prompts/gpt5-prompt.d.ts +8 -0
- package/dist/tools/prompts/gpt5-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/gpt5-prompt.js +177 -0
- package/dist/tools/prompts/gpt5-prompt.js.map +1 -0
- package/dist/tools/prompts/gpt51-prompt.d.ts +8 -0
- package/dist/tools/prompts/gpt51-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/gpt51-prompt.js +178 -0
- package/dist/tools/prompts/gpt51-prompt.js.map +1 -0
- package/dist/tools/prompts/gpt52-prompt.d.ts +8 -0
- package/dist/tools/prompts/gpt52-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/gpt52-prompt.js +198 -0
- package/dist/tools/prompts/gpt52-prompt.js.map +1 -0
- package/dist/tools/prompts/index.d.ts +22 -0
- package/dist/tools/prompts/index.d.ts.map +1 -0
- package/dist/tools/prompts/index.js +23 -0
- package/dist/tools/prompts/index.js.map +1 -0
- package/dist/tools/prompts/prompt-registry.d.ts +44 -0
- package/dist/tools/prompts/prompt-registry.d.ts.map +1 -0
- package/dist/tools/prompts/prompt-registry.js +60 -0
- package/dist/tools/prompts/prompt-registry.js.map +1 -0
- package/dist/tools/prompts/shared-sections.d.ts +28 -0
- package/dist/tools/prompts/shared-sections.d.ts.map +1 -0
- package/dist/tools/prompts/shared-sections.js +111 -0
- package/dist/tools/prompts/shared-sections.js.map +1 -0
- package/dist/tools/prompts/xai-prompt.d.ts +8 -0
- package/dist/tools/prompts/xai-prompt.d.ts.map +1 -0
- package/dist/tools/prompts/xai-prompt.js +68 -0
- package/dist/tools/prompts/xai-prompt.js.map +1 -0
- package/dist/tools/redeploy-tools.d.ts +30 -0
- package/dist/tools/redeploy-tools.d.ts.map +1 -0
- package/dist/tools/redeploy-tools.js +191 -0
- package/dist/tools/redeploy-tools.js.map +1 -0
- package/dist/tools/registry.d.ts +51 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +148 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/screen-share-tools.d.ts +31 -0
- package/dist/tools/screen-share-tools.d.ts.map +1 -0
- package/dist/tools/screen-share-tools.js +183 -0
- package/dist/tools/screen-share-tools.js.map +1 -0
- package/dist/tools/surface-tools.d.ts +23 -0
- package/dist/tools/surface-tools.d.ts.map +1 -0
- package/dist/tools/surface-tools.js +99 -0
- package/dist/tools/surface-tools.js.map +1 -0
- package/dist/tools/terminal-tools.d.ts +37 -0
- package/dist/tools/terminal-tools.d.ts.map +1 -0
- package/dist/tools/terminal-tools.js +448 -0
- package/dist/tools/terminal-tools.js.map +1 -0
- package/dist/tools/thread-tools.d.ts +61 -0
- package/dist/tools/thread-tools.d.ts.map +1 -0
- package/dist/tools/thread-tools.js +484 -0
- package/dist/tools/thread-tools.js.map +1 -0
- package/dist/tools/token-estimator.d.ts +55 -0
- package/dist/tools/token-estimator.d.ts.map +1 -0
- package/dist/tools/token-estimator.js +82 -0
- package/dist/tools/token-estimator.js.map +1 -0
- package/dist/tools/tool-names.d.ts +64 -0
- package/dist/tools/tool-names.d.ts.map +1 -0
- package/dist/tools/tool-names.js +76 -0
- package/dist/tools/tool-names.js.map +1 -0
- package/dist/tools/validate.d.ts +27 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +99 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/tools/voice-tools.d.ts +8 -0
- package/dist/tools/voice-tools.d.ts.map +1 -0
- package/dist/tools/voice-tools.js +32 -0
- package/dist/tools/voice-tools.js.map +1 -0
- package/dist/voice/service.d.ts +42 -0
- package/dist/voice/service.d.ts.map +1 -0
- package/dist/voice/service.js +75 -0
- package/dist/voice/service.js.map +1 -0
- package/dist/ws.d.ts +90 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +562 -0
- package/dist/ws.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,1503 @@
|
|
|
1
|
+
import { inferContextWindow } from "../config.js";
|
|
2
|
+
import { FileSystemSurface } from "../surfaces/filesystem.js";
|
|
3
|
+
import { resolveWorkspaceRoot } from "../tools/core/get-fs.js";
|
|
4
|
+
import { messages as messagesTable } from "../db/schema.js";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { uuidv7 } from "../lib/uuidv7.js";
|
|
7
|
+
import { requireAuth } from "../security/http-auth.js";
|
|
8
|
+
import { runAgentLoop, retryToolCall, buildTieredToolSchemas, fromOpenAIName, SteeringController, } from "../tools/agent-loop.js";
|
|
9
|
+
import { isValidChatMode, } from "../tools/chat-modes.js";
|
|
10
|
+
import { buildSystemPrompt } from "../tools/prompts/index.js";
|
|
11
|
+
// ── In-memory state ──────────────────────────────────────────────────
|
|
12
|
+
const sessionHistory = new Map();
|
|
13
|
+
const activeStreams = new Set();
|
|
14
|
+
const sessionAbortControllers = new Map();
|
|
15
|
+
/** Persistent CLI provider sessions — kept alive across turns so the agent retains conversation context */
|
|
16
|
+
const activeCliSessions = new Map();
|
|
17
|
+
const sessionSubscribers = new Map();
|
|
18
|
+
const DEFAULT_UI_MESSAGE_LIMIT = 120;
|
|
19
|
+
const MAX_UI_MESSAGE_LIMIT = 500;
|
|
20
|
+
function parseToolArguments(raw) {
|
|
21
|
+
if (!raw)
|
|
22
|
+
return {};
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function mapPendingToolCallsForUI(toolCalls, resultStateByCallId) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
return toolCalls.map((tc) => ({
|
|
33
|
+
callId: tc.id,
|
|
34
|
+
tool: fromOpenAIName(tc.function.name),
|
|
35
|
+
args: parseToolArguments(tc.function.arguments),
|
|
36
|
+
...(resultStateByCallId?.has(tc.id)
|
|
37
|
+
? {
|
|
38
|
+
status: resultStateByCallId.get(tc.id).ok ? "success" : "error",
|
|
39
|
+
ok: resultStateByCallId.get(tc.id).ok,
|
|
40
|
+
message: resultStateByCallId.get(tc.id).message,
|
|
41
|
+
data: resultStateByCallId.get(tc.id).data,
|
|
42
|
+
completedAt: now,
|
|
43
|
+
}
|
|
44
|
+
: {
|
|
45
|
+
status: "running",
|
|
46
|
+
startedAt: now,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
function mapPersistedToolCallsForUI(toolCalls) {
|
|
51
|
+
return toolCalls.map((tc) => ({
|
|
52
|
+
callId: tc.callId,
|
|
53
|
+
tool: tc.tool,
|
|
54
|
+
args: (typeof tc.args === "object" && tc.args !== null ? tc.args : {}),
|
|
55
|
+
status: tc.ok ? "success" : "error",
|
|
56
|
+
ok: tc.ok,
|
|
57
|
+
message: tc.message,
|
|
58
|
+
output: tc.output,
|
|
59
|
+
data: tc.data,
|
|
60
|
+
startedAt: tc.startedAt,
|
|
61
|
+
completedAt: tc.completedAt,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
function buildToolResultStateMap(history) {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
for (const msg of history) {
|
|
67
|
+
if (msg.role !== "tool" || !msg.tool_call_id)
|
|
68
|
+
continue;
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(msg.content);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Keep best effort fallback below.
|
|
75
|
+
}
|
|
76
|
+
const ok = typeof parsed?.ok === "boolean" ? parsed.ok : false;
|
|
77
|
+
const message = typeof parsed?.message === "string"
|
|
78
|
+
? parsed.message
|
|
79
|
+
: (msg.content?.trim() || (ok ? "Completed" : "Failed"));
|
|
80
|
+
out.set(msg.tool_call_id, {
|
|
81
|
+
ok,
|
|
82
|
+
message,
|
|
83
|
+
data: parsed?.data,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function emitToSubscribers(sessionId, event) {
|
|
89
|
+
const subs = sessionSubscribers.get(sessionId);
|
|
90
|
+
if (subs)
|
|
91
|
+
for (const fn of subs)
|
|
92
|
+
fn(event);
|
|
93
|
+
}
|
|
94
|
+
function subscribe(sessionId, fn) {
|
|
95
|
+
if (!sessionSubscribers.has(sessionId))
|
|
96
|
+
sessionSubscribers.set(sessionId, new Set());
|
|
97
|
+
sessionSubscribers.get(sessionId).add(fn);
|
|
98
|
+
return () => {
|
|
99
|
+
const subs = sessionSubscribers.get(sessionId);
|
|
100
|
+
if (subs) {
|
|
101
|
+
subs.delete(fn);
|
|
102
|
+
if (subs.size === 0)
|
|
103
|
+
sessionSubscribers.delete(sessionId);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseMessageLimit(raw) {
|
|
108
|
+
const parsed = typeof raw === "number"
|
|
109
|
+
? raw
|
|
110
|
+
: typeof raw === "string"
|
|
111
|
+
? Number.parseInt(raw, 10)
|
|
112
|
+
: Number.NaN;
|
|
113
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
114
|
+
return DEFAULT_UI_MESSAGE_LIMIT;
|
|
115
|
+
return Math.min(Math.floor(parsed), MAX_UI_MESSAGE_LIMIT);
|
|
116
|
+
}
|
|
117
|
+
function windowMessages(messages, limit) {
|
|
118
|
+
const total = messages.length;
|
|
119
|
+
const start = Math.max(total - limit, 0);
|
|
120
|
+
return {
|
|
121
|
+
messages: messages.slice(start),
|
|
122
|
+
total,
|
|
123
|
+
hasMore: start > 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function sleep(ms) {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
function buildVisibleHistoryEntries(sessionId, history, options) {
|
|
130
|
+
const out = [];
|
|
131
|
+
let visibleIndex = 0;
|
|
132
|
+
const includePendingAssistantToolCalls = options?.includePendingAssistantToolCalls === true;
|
|
133
|
+
const toolResultStateByCallId = includePendingAssistantToolCalls
|
|
134
|
+
? buildToolResultStateMap(history)
|
|
135
|
+
: undefined;
|
|
136
|
+
for (let i = 0; i < history.length; i++) {
|
|
137
|
+
const m = history[i];
|
|
138
|
+
if (m.role === "system" || m.role === "tool")
|
|
139
|
+
continue;
|
|
140
|
+
let uiToolCalls;
|
|
141
|
+
if (m.role === "assistant") {
|
|
142
|
+
if (Array.isArray(m.uiToolCalls) && m.uiToolCalls.length > 0) {
|
|
143
|
+
uiToolCalls = mapPersistedToolCallsForUI(m.uiToolCalls);
|
|
144
|
+
}
|
|
145
|
+
else if (m.tool_calls && includePendingAssistantToolCalls) {
|
|
146
|
+
uiToolCalls = mapPendingToolCallsForUI(m.tool_calls, toolResultStateByCallId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (m.role === "assistant" && m.tool_calls && !m.content && !includePendingAssistantToolCalls) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
out.push({
|
|
153
|
+
id: `${sessionId}-${visibleIndex}`,
|
|
154
|
+
role: m.role,
|
|
155
|
+
content: m.content,
|
|
156
|
+
toolCalls: uiToolCalls,
|
|
157
|
+
segments: m.segments,
|
|
158
|
+
historyIndex: i,
|
|
159
|
+
});
|
|
160
|
+
visibleIndex++;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function buildVisibleHistoryMessages(sessionId, history, options) {
|
|
165
|
+
return buildVisibleHistoryEntries(sessionId, history, options).map(({ id, role, content, toolCalls, segments }) => ({
|
|
166
|
+
id,
|
|
167
|
+
role,
|
|
168
|
+
content,
|
|
169
|
+
toolCalls,
|
|
170
|
+
segments,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
// ── System prompt ────────────────────────────────────────────────────
|
|
174
|
+
const SYSTEM_PROMPT = `You are Jait — Just Another Intelligent Tool. You are a capable AI assistant that can run shell commands, read/write files, and manage system surfaces.
|
|
175
|
+
|
|
176
|
+
When the user asks you to do something that requires action (run a command, edit a file, check system info, etc.), use your tools. Don't just describe what you would do — actually do it.
|
|
177
|
+
|
|
178
|
+
Key capabilities:
|
|
179
|
+
- terminal.run: Execute shell commands (PowerShell on Windows). Always use this to run commands.
|
|
180
|
+
- file.read / file.write / file.patch: Read, create, and edit files.
|
|
181
|
+
- file.list / file.stat: Browse the filesystem.
|
|
182
|
+
- os.query: Get system info, running processes, disk usage.
|
|
183
|
+
- surfaces.list / surfaces.start / surfaces.stop: Manage terminal and filesystem surfaces.
|
|
184
|
+
- cron.add / cron.list / cron.update / cron.remove: Create and manage recurring Jait jobs.
|
|
185
|
+
|
|
186
|
+
Guidelines:
|
|
187
|
+
- Be direct and concise.
|
|
188
|
+
- When running commands, use the actual tools — don't just suggest commands.
|
|
189
|
+
- For multi-step tasks, execute them step by step, checking each result.
|
|
190
|
+
- If a command fails, analyze the error and try to fix it.
|
|
191
|
+
- When editing files, read them first to understand the context before patching.
|
|
192
|
+
- For recurring or scheduled automation requests, prefer cron tools and Jait jobs instead of OS-native schedulers.
|
|
193
|
+
- Do not create Windows Task Scheduler jobs unless the user explicitly asks for OS-native scheduling.`;
|
|
194
|
+
/** Max agentic loop iterations to prevent infinite loops */
|
|
195
|
+
const MAX_TOOL_ROUNDS = 15;
|
|
196
|
+
// ── Module-level DB ref for persistence from extracted functions ──────
|
|
197
|
+
let _dbRef;
|
|
198
|
+
let _appRef;
|
|
199
|
+
function persistMessageGlobal(sessionId, role, content, toolCalls, segments) {
|
|
200
|
+
if (!_dbRef)
|
|
201
|
+
return;
|
|
202
|
+
try {
|
|
203
|
+
_dbRef.insert(messagesTable)
|
|
204
|
+
.values({
|
|
205
|
+
id: crypto.randomUUID(),
|
|
206
|
+
sessionId,
|
|
207
|
+
role,
|
|
208
|
+
content,
|
|
209
|
+
toolCalls: toolCalls ?? null,
|
|
210
|
+
segments: segments ?? null,
|
|
211
|
+
createdAt: new Date().toISOString(),
|
|
212
|
+
})
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
_appRef?.log.error(err, "Failed to persist message");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
|
|
220
|
+
// Support both old signature (db, sessionService) and new deps object
|
|
221
|
+
let db;
|
|
222
|
+
let sessionService;
|
|
223
|
+
let userService;
|
|
224
|
+
let toolRegistry;
|
|
225
|
+
let surfaceRegistry;
|
|
226
|
+
let audit;
|
|
227
|
+
let toolExecutor;
|
|
228
|
+
let memoryService;
|
|
229
|
+
let ws;
|
|
230
|
+
let sessionStateService;
|
|
231
|
+
let providerRegistry;
|
|
232
|
+
if (depsOrDb && typeof depsOrDb === "object" && "sessionService" in depsOrDb) {
|
|
233
|
+
const deps = depsOrDb;
|
|
234
|
+
db = deps.db;
|
|
235
|
+
sessionService = deps.sessionService;
|
|
236
|
+
userService = deps.userService;
|
|
237
|
+
toolRegistry = deps.toolRegistry;
|
|
238
|
+
surfaceRegistry = deps.surfaceRegistry;
|
|
239
|
+
audit = deps.audit;
|
|
240
|
+
toolExecutor = deps.toolExecutor;
|
|
241
|
+
memoryService = deps.memoryService;
|
|
242
|
+
ws = deps.ws;
|
|
243
|
+
sessionStateService = deps.sessionState;
|
|
244
|
+
providerRegistry = deps.providerRegistry;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
db = depsOrDb;
|
|
248
|
+
sessionService = sessionServiceArg;
|
|
249
|
+
}
|
|
250
|
+
// Store refs for persistence from extracted functions
|
|
251
|
+
_dbRef = db;
|
|
252
|
+
_appRef = app;
|
|
253
|
+
const hasTools = !!toolRegistry && toolRegistry.list().length > 0;
|
|
254
|
+
// ── Per-session steering controllers and executed tool call tracking ──
|
|
255
|
+
const sessionSteeringControllers = new Map();
|
|
256
|
+
const sessionExecutedToolCalls = new Map();
|
|
257
|
+
/** Plans produced by plan mode — keyed by session ID */
|
|
258
|
+
const sessionPlans = new Map();
|
|
259
|
+
app.log.info(`Chat route: ${hasTools ? toolRegistry.list().length + " tools available for agent (tiered)" : "no tools (text-only mode)"}`);
|
|
260
|
+
// Hydrate in-memory cache from DB if session not yet loaded
|
|
261
|
+
function hydrateSession(sessionId) {
|
|
262
|
+
if (sessionHistory.has(sessionId))
|
|
263
|
+
return;
|
|
264
|
+
if (!db)
|
|
265
|
+
return;
|
|
266
|
+
const rows = db
|
|
267
|
+
.select()
|
|
268
|
+
.from(messagesTable)
|
|
269
|
+
.where(eq(messagesTable.sessionId, sessionId))
|
|
270
|
+
.orderBy(messagesTable.createdAt)
|
|
271
|
+
.all();
|
|
272
|
+
if (rows.length > 0) {
|
|
273
|
+
sessionHistory.set(sessionId, [
|
|
274
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
275
|
+
...rows.map((r) => {
|
|
276
|
+
let uiToolCalls;
|
|
277
|
+
let segments;
|
|
278
|
+
if (r.toolCalls) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(r.toolCalls);
|
|
281
|
+
if (Array.isArray(parsed)) {
|
|
282
|
+
uiToolCalls = parsed;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// Ignore malformed historical toolCalls payloads.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (r.segments) {
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(r.segments);
|
|
292
|
+
if (Array.isArray(parsed)) {
|
|
293
|
+
segments = parsed;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch { /* ignore */ }
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
role: r.role,
|
|
300
|
+
content: r.content,
|
|
301
|
+
uiToolCalls,
|
|
302
|
+
segments,
|
|
303
|
+
};
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function persistMessage(sessionId, role, content, toolCalls, segments) {
|
|
309
|
+
if (!db)
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
db.insert(messagesTable)
|
|
313
|
+
.values({
|
|
314
|
+
id: crypto.randomUUID(),
|
|
315
|
+
sessionId,
|
|
316
|
+
role,
|
|
317
|
+
content,
|
|
318
|
+
toolCalls: toolCalls ?? null,
|
|
319
|
+
segments: segments ?? null,
|
|
320
|
+
createdAt: new Date().toISOString(),
|
|
321
|
+
})
|
|
322
|
+
.run();
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
app.log.error(err, "Failed to persist message");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ── Tool execution helper ──────────────────────────────────────────
|
|
329
|
+
async function executeTool(toolName, args, sessionId, auth, onOutputChunk, signal) {
|
|
330
|
+
if (!toolRegistry) {
|
|
331
|
+
return { ok: false, message: "Tool registry not available" };
|
|
332
|
+
}
|
|
333
|
+
if (signal?.aborted) {
|
|
334
|
+
return { ok: false, message: "Cancelled" };
|
|
335
|
+
}
|
|
336
|
+
const context = {
|
|
337
|
+
sessionId,
|
|
338
|
+
actionId: uuidv7(),
|
|
339
|
+
workspaceRoot: surfaceRegistry
|
|
340
|
+
? resolveWorkspaceRoot(surfaceRegistry, sessionId)
|
|
341
|
+
: process.cwd(),
|
|
342
|
+
requestedBy: "agent",
|
|
343
|
+
userId: auth?.userId,
|
|
344
|
+
apiKeys: auth?.apiKeys,
|
|
345
|
+
onOutputChunk,
|
|
346
|
+
signal,
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
const toolPromise = toolExecutor
|
|
350
|
+
? toolExecutor(toolName, args, context)
|
|
351
|
+
: toolRegistry.execute(toolName, args, context, audit);
|
|
352
|
+
// Race the tool execution against the abort signal so a stuck tool
|
|
353
|
+
// (e.g. browser launch hanging) doesn't block the cancel flow forever.
|
|
354
|
+
if (signal && !signal.aborted) {
|
|
355
|
+
const abortPromise = new Promise((resolve) => {
|
|
356
|
+
const onAbort = () => resolve({ ok: false, message: "Cancelled" });
|
|
357
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
358
|
+
// Clean up if the tool finishes first
|
|
359
|
+
toolPromise.finally(() => signal.removeEventListener("abort", onAbort));
|
|
360
|
+
});
|
|
361
|
+
return await Promise.race([toolPromise, abortPromise]);
|
|
362
|
+
}
|
|
363
|
+
return await toolPromise;
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
if (signal?.aborted)
|
|
367
|
+
return { ok: false, message: "Cancelled" };
|
|
368
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ══ POST /api/chat — Main chat endpoint with agentic tool loop ═════
|
|
372
|
+
app.post("/api/chat", async (request, reply) => {
|
|
373
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
374
|
+
if (!authUser)
|
|
375
|
+
return;
|
|
376
|
+
const body = request.body;
|
|
377
|
+
const content = typeof body["content"] === "string"
|
|
378
|
+
? body["content"]
|
|
379
|
+
: typeof body["message"] === "string"
|
|
380
|
+
? body["message"]
|
|
381
|
+
: "";
|
|
382
|
+
const sessionId = typeof body["sessionId"] === "string"
|
|
383
|
+
? body["sessionId"]
|
|
384
|
+
: typeof body["session_id"] === "string"
|
|
385
|
+
? body["session_id"]
|
|
386
|
+
: crypto.randomUUID();
|
|
387
|
+
const chatMode = isValidChatMode(body["mode"]) ? body["mode"] : "agent";
|
|
388
|
+
const requestProvider = typeof body["provider"] === "string"
|
|
389
|
+
? body["provider"]
|
|
390
|
+
: undefined;
|
|
391
|
+
if (!content.trim()) {
|
|
392
|
+
return reply
|
|
393
|
+
.status(400)
|
|
394
|
+
.send({ error: "VALIDATION_ERROR", details: "content is required" });
|
|
395
|
+
}
|
|
396
|
+
if (sessionService) {
|
|
397
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
398
|
+
if (!session) {
|
|
399
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const userApiKeys = userService?.getSettings(authUser.id).apiKeys ?? {};
|
|
403
|
+
const effectiveModel = userApiKeys["OPENAI_MODEL"]?.trim() || config.openaiModel;
|
|
404
|
+
const llmRuntime = {
|
|
405
|
+
openaiApiKey: userApiKeys["OPENAI_API_KEY"]?.trim() || config.openaiApiKey,
|
|
406
|
+
openaiBaseUrl: userApiKeys["OPENAI_BASE_URL"]?.trim() || config.openaiBaseUrl,
|
|
407
|
+
openaiModel: effectiveModel,
|
|
408
|
+
contextWindow: userApiKeys["OPENAI_MODEL"]?.trim()
|
|
409
|
+
? inferContextWindow(effectiveModel)
|
|
410
|
+
: config.contextWindow,
|
|
411
|
+
};
|
|
412
|
+
// Set SSE headers
|
|
413
|
+
reply.raw.writeHead(200, {
|
|
414
|
+
"Content-Type": "text/event-stream",
|
|
415
|
+
"Cache-Control": "no-cache",
|
|
416
|
+
Connection: "keep-alive",
|
|
417
|
+
});
|
|
418
|
+
// Build model endpoint for prompt resolution
|
|
419
|
+
const modelEndpoint = {
|
|
420
|
+
model: llmRuntime.openaiModel,
|
|
421
|
+
baseUrl: llmRuntime.openaiBaseUrl,
|
|
422
|
+
};
|
|
423
|
+
// Build conversation history (hydrate from DB if needed)
|
|
424
|
+
hydrateSession(sessionId);
|
|
425
|
+
// Resolve workspace root so the system prompt includes it
|
|
426
|
+
const sessionRecord = sessionService?.getById(sessionId);
|
|
427
|
+
const wsRoot = surfaceRegistry
|
|
428
|
+
? resolveWorkspaceRoot(surfaceRegistry, sessionId, sessionRecord?.workspacePath)
|
|
429
|
+
: (sessionRecord?.workspacePath?.trim() || process.cwd());
|
|
430
|
+
const promptCtx = { workspaceRoot: wsRoot };
|
|
431
|
+
if (!sessionHistory.has(sessionId)) {
|
|
432
|
+
sessionHistory.set(sessionId, [
|
|
433
|
+
{ role: "system", content: buildSystemPrompt(chatMode, modelEndpoint, promptCtx) },
|
|
434
|
+
]);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
// Update system prompt if mode/model/workspace changed mid-session
|
|
438
|
+
const h = sessionHistory.get(sessionId);
|
|
439
|
+
const modePrompt = buildSystemPrompt(chatMode, modelEndpoint, promptCtx);
|
|
440
|
+
if (h[0]?.role === "system" && h[0].content !== modePrompt) {
|
|
441
|
+
h[0] = { role: "system", content: modePrompt };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const history = sessionHistory.get(sessionId);
|
|
445
|
+
history.push({ role: "user", content });
|
|
446
|
+
persistMessage(sessionId, "user", content);
|
|
447
|
+
try {
|
|
448
|
+
sessionService?.touch(sessionId);
|
|
449
|
+
}
|
|
450
|
+
catch { /* session may not exist */ }
|
|
451
|
+
const streamAbort = new AbortController();
|
|
452
|
+
sessionAbortControllers.set(sessionId, streamAbort);
|
|
453
|
+
let fullContent = "";
|
|
454
|
+
let partialToolCalls = [];
|
|
455
|
+
let resultSegmentsJson;
|
|
456
|
+
let hitMaxRounds = false;
|
|
457
|
+
activeStreams.add(sessionId);
|
|
458
|
+
let clientDisconnected = false;
|
|
459
|
+
reply.raw.on("close", () => { clientDisconnected = true; });
|
|
460
|
+
const safeWrite = (data) => {
|
|
461
|
+
if (!clientDisconnected) {
|
|
462
|
+
try {
|
|
463
|
+
reply.raw.write(data);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
clientDisconnected = true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const providerLabel = requestProvider === "codex"
|
|
471
|
+
? "Codex"
|
|
472
|
+
: requestProvider === "claude-code"
|
|
473
|
+
? "Claude Code"
|
|
474
|
+
: config.llmProvider === "openai" ? "OpenAI" : "Ollama";
|
|
475
|
+
// Create steering controller for this session
|
|
476
|
+
const steering = new SteeringController();
|
|
477
|
+
sessionSteeringControllers.set(sessionId, steering);
|
|
478
|
+
try {
|
|
479
|
+
// ══ CLI Provider path (codex / claude-code via MCP) ══════════
|
|
480
|
+
if (requestProvider && requestProvider !== "jait" && providerRegistry) {
|
|
481
|
+
const cliProvider = providerRegistry.get(requestProvider);
|
|
482
|
+
if (!cliProvider) {
|
|
483
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: `Unknown provider: ${requestProvider}` })}\n\n`);
|
|
484
|
+
reply.raw.end();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const available = await cliProvider.checkAvailability();
|
|
488
|
+
if (!available) {
|
|
489
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: `Provider ${requestProvider} is not available: ${cliProvider.info.unavailableReason}` })}\n\n`);
|
|
490
|
+
reply.raw.end();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const cliWsRoot = surfaceRegistry
|
|
494
|
+
? resolveWorkspaceRoot(surfaceRegistry, sessionId, sessionRecord?.workspacePath)
|
|
495
|
+
: (sessionRecord?.workspacePath?.trim() || process.cwd());
|
|
496
|
+
console.log(`[chat/cli] session=${sessionId} wsRoot="${cliWsRoot}" session.workspacePath="${sessionRecord?.workspacePath}" surfaces=${surfaceRegistry?.getBySession(sessionId)?.length ?? 0}`);
|
|
497
|
+
// Ensure a FileSystemSurface exists for this session so we can
|
|
498
|
+
// back up files before CLI providers (Codex/Claude) write them,
|
|
499
|
+
// enabling the keep/discard (undo) flow.
|
|
500
|
+
let cliFsSurface = null;
|
|
501
|
+
if (surfaceRegistry) {
|
|
502
|
+
const fsId = `fs-${sessionId}`;
|
|
503
|
+
const existing = surfaceRegistry.getSurface(fsId);
|
|
504
|
+
if (existing instanceof FileSystemSurface && existing.state === "running") {
|
|
505
|
+
cliFsSurface = existing;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
try {
|
|
509
|
+
const started = await surfaceRegistry.startSurface("filesystem", fsId, {
|
|
510
|
+
sessionId,
|
|
511
|
+
workspaceRoot: cliWsRoot,
|
|
512
|
+
});
|
|
513
|
+
cliFsSurface = started;
|
|
514
|
+
}
|
|
515
|
+
catch { /* best effort */ }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const mcpServers = [providerRegistry.buildJaitMcpServerRef(config)];
|
|
519
|
+
// ── Reuse an existing CLI session if one is alive for this Jait session ──
|
|
520
|
+
const cachedCliSession = activeCliSessions.get(sessionId);
|
|
521
|
+
let providerSessionId;
|
|
522
|
+
if (cachedCliSession && cachedCliSession.providerId === requestProvider) {
|
|
523
|
+
// Existing session with the same provider — try to reuse it
|
|
524
|
+
providerSessionId = cachedCliSession.providerSessionId;
|
|
525
|
+
console.log(`[chat/cli] Reusing ${requestProvider} session ${providerSessionId} for ${sessionId}`);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
// If the user switched providers, stop the old session first
|
|
529
|
+
if (cachedCliSession) {
|
|
530
|
+
const oldProvider = providerRegistry.get(cachedCliSession.providerId);
|
|
531
|
+
if (oldProvider) {
|
|
532
|
+
try {
|
|
533
|
+
await oldProvider.stopSession(cachedCliSession.providerSessionId);
|
|
534
|
+
}
|
|
535
|
+
catch { /* best effort */ }
|
|
536
|
+
}
|
|
537
|
+
activeCliSessions.delete(sessionId);
|
|
538
|
+
}
|
|
539
|
+
const session = await cliProvider.startSession({
|
|
540
|
+
threadId: sessionId,
|
|
541
|
+
workingDirectory: cliWsRoot,
|
|
542
|
+
mode: "full-access",
|
|
543
|
+
model: typeof body["model"] === "string" ? body["model"] : undefined,
|
|
544
|
+
mcpServers,
|
|
545
|
+
});
|
|
546
|
+
providerSessionId = session.id;
|
|
547
|
+
activeCliSessions.set(sessionId, { providerId: requestProvider, providerSessionId });
|
|
548
|
+
console.log(`[chat/cli] Started new ${requestProvider} session ${providerSessionId} for ${sessionId}`);
|
|
549
|
+
}
|
|
550
|
+
// Collect full content from CLI provider events
|
|
551
|
+
const contentChunks = [];
|
|
552
|
+
// ── Accumulate tool calls + segments for persistence ──
|
|
553
|
+
const cliToolCalls = [];
|
|
554
|
+
const cliSegments = [];
|
|
555
|
+
/** Track the current pending tool-group callIds (batched between text tokens) */
|
|
556
|
+
let pendingToolGroup = [];
|
|
557
|
+
let lastSegmentWasText = false;
|
|
558
|
+
/** Flush any buffered text into a text segment */
|
|
559
|
+
const flushTextSegment = () => {
|
|
560
|
+
if (lastSegmentWasText)
|
|
561
|
+
return; // already flushed
|
|
562
|
+
const text = contentChunks.join("");
|
|
563
|
+
// Only create a segment if there's new text since the last tool group
|
|
564
|
+
const prevTextLen = cliSegments
|
|
565
|
+
.filter((s) => s.type === "text")
|
|
566
|
+
.reduce((n, s) => n + s.content.length, 0);
|
|
567
|
+
const newText = text.slice(prevTextLen);
|
|
568
|
+
if (newText) {
|
|
569
|
+
cliSegments.push({ type: "text", content: newText });
|
|
570
|
+
}
|
|
571
|
+
lastSegmentWasText = true;
|
|
572
|
+
};
|
|
573
|
+
/** Flush any pending tool group into a segment */
|
|
574
|
+
const flushToolGroup = () => {
|
|
575
|
+
if (pendingToolGroup.length > 0) {
|
|
576
|
+
// Before adding a tool group, flush any preceding text
|
|
577
|
+
flushTextSegment();
|
|
578
|
+
cliSegments.push({ type: "toolGroup", callIds: [...pendingToolGroup] });
|
|
579
|
+
pendingToolGroup = [];
|
|
580
|
+
lastSegmentWasText = false;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const unsubscribe = cliProvider.onEvent((event) => {
|
|
584
|
+
if (event.sessionId !== providerSessionId) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Map provider events to SSE events the frontend understands
|
|
588
|
+
switch (event.type) {
|
|
589
|
+
case "token":
|
|
590
|
+
// If there's a pending tool group, flush it first
|
|
591
|
+
flushToolGroup();
|
|
592
|
+
contentChunks.push(event.content);
|
|
593
|
+
lastSegmentWasText = false; // new text arrived
|
|
594
|
+
safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
|
|
595
|
+
emitToSubscribers(sessionId, { type: "token", content: event.content });
|
|
596
|
+
break;
|
|
597
|
+
case "tool.start": {
|
|
598
|
+
const callId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
599
|
+
// Accumulate for persistence
|
|
600
|
+
cliToolCalls.push({
|
|
601
|
+
callId,
|
|
602
|
+
tool: event.tool,
|
|
603
|
+
args: event.args ?? {},
|
|
604
|
+
ok: true,
|
|
605
|
+
message: "",
|
|
606
|
+
startedAt: Date.now(),
|
|
607
|
+
});
|
|
608
|
+
pendingToolGroup.push(callId);
|
|
609
|
+
// Save backup of original file *before* CLI provider writes it
|
|
610
|
+
if (event.tool === "edit" && cliFsSurface) {
|
|
611
|
+
const editPath = String(event.args?.path ?? "");
|
|
612
|
+
if (editPath) {
|
|
613
|
+
cliFsSurface.saveExternalBackup(editPath).catch(() => { });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
safeWrite(`data: ${JSON.stringify({ type: "tool_start", call_id: callId, tool: event.tool, args: event.args })}\n\n`);
|
|
617
|
+
emitToSubscribers(sessionId, { type: "tool_start", call_id: callId, tool: event.tool, args: event.args });
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case "tool.output": {
|
|
621
|
+
// Accumulate streaming output on the matching tool call
|
|
622
|
+
const tc = cliToolCalls.find(t => t.callId === event.callId);
|
|
623
|
+
if (tc) {
|
|
624
|
+
tc.message = (tc.message || "") + event.content;
|
|
625
|
+
}
|
|
626
|
+
safeWrite(`data: ${JSON.stringify({ type: "tool_output", call_id: event.callId, content: event.content })}\n\n`);
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
case "tool.result": {
|
|
630
|
+
const resultCallId = event.callId ?? `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
631
|
+
// Update the matching tool call record
|
|
632
|
+
const tc = cliToolCalls.find(t => t.callId === resultCallId);
|
|
633
|
+
if (tc) {
|
|
634
|
+
tc.ok = event.ok;
|
|
635
|
+
tc.message = event.message || tc.message;
|
|
636
|
+
tc.data = event.data;
|
|
637
|
+
tc.completedAt = Date.now();
|
|
638
|
+
}
|
|
639
|
+
safeWrite(`data: ${JSON.stringify({ type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data })}\n\n`);
|
|
640
|
+
emitToSubscribers(sessionId, { type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message });
|
|
641
|
+
// Emit file_changed for successful edits → drives the keep/discard UI
|
|
642
|
+
if (event.ok && event.tool === "edit") {
|
|
643
|
+
const editPath = String(tc?.args ? tc.args.path ?? "" : "");
|
|
644
|
+
if (editPath) {
|
|
645
|
+
const editName = editPath.split(/[\/\\]/).pop() ?? editPath;
|
|
646
|
+
safeWrite(`data: ${JSON.stringify({ type: "file_changed", path: editPath, name: editName })}\n\n`);
|
|
647
|
+
// Broadcast to other session clients
|
|
648
|
+
if (ws) {
|
|
649
|
+
ws.broadcast(sessionId, {
|
|
650
|
+
type: "ui.state-sync",
|
|
651
|
+
sessionId,
|
|
652
|
+
timestamp: new Date().toISOString(),
|
|
653
|
+
payload: { key: "file_changed", value: { path: editPath, name: editName } },
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
// Persist cumulative changed files list
|
|
657
|
+
if (sessionStateService) {
|
|
658
|
+
try {
|
|
659
|
+
const existing = sessionStateService.get(sessionId, ["changed_files"]);
|
|
660
|
+
const files = Array.isArray(existing["changed_files"]) ? existing["changed_files"] : [];
|
|
661
|
+
if (!files.some((f) => f.path === editPath)) {
|
|
662
|
+
files.push({ path: editPath, name: editName });
|
|
663
|
+
sessionStateService.set(sessionId, { changed_files: files });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch { /* ignore */ }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case "tool.approval-required":
|
|
673
|
+
safeWrite(`data: ${JSON.stringify({ type: "approval_required", tool: event.tool, args: event.args, requestId: event.requestId })}\n\n`);
|
|
674
|
+
break;
|
|
675
|
+
case "message":
|
|
676
|
+
if (event.role === "assistant") {
|
|
677
|
+
flushToolGroup();
|
|
678
|
+
contentChunks.push(event.content);
|
|
679
|
+
lastSegmentWasText = false;
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
case "session.error":
|
|
683
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: event.error })}\n\n`);
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
// Send the turn — with recovery if the cached session died between messages
|
|
688
|
+
try {
|
|
689
|
+
await cliProvider.sendTurn(providerSessionId, content);
|
|
690
|
+
}
|
|
691
|
+
catch (sendErr) {
|
|
692
|
+
// Session likely died (process exited) — start a fresh one
|
|
693
|
+
console.warn(`[chat/cli] sendTurn failed on cached session, recovering:`, sendErr);
|
|
694
|
+
activeCliSessions.delete(sessionId);
|
|
695
|
+
const freshSession = await cliProvider.startSession({
|
|
696
|
+
threadId: sessionId,
|
|
697
|
+
workingDirectory: cliWsRoot,
|
|
698
|
+
mode: "full-access",
|
|
699
|
+
model: typeof body["model"] === "string" ? body["model"] : undefined,
|
|
700
|
+
mcpServers,
|
|
701
|
+
});
|
|
702
|
+
providerSessionId = freshSession.id;
|
|
703
|
+
activeCliSessions.set(sessionId, { providerId: requestProvider, providerSessionId });
|
|
704
|
+
console.log(`[chat/cli] Recovered with new session ${providerSessionId}`);
|
|
705
|
+
await cliProvider.sendTurn(providerSessionId, content);
|
|
706
|
+
}
|
|
707
|
+
// Wait for turn completion or error
|
|
708
|
+
await new Promise((resolve) => {
|
|
709
|
+
const checkDone = cliProvider.onEvent((event) => {
|
|
710
|
+
if (event.sessionId !== providerSessionId) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (event.type === "session.completed" || event.type === "session.error") {
|
|
714
|
+
// If the session errored, invalidate the cache so the next message creates a fresh one
|
|
715
|
+
if (event.type === "session.error") {
|
|
716
|
+
activeCliSessions.delete(sessionId);
|
|
717
|
+
}
|
|
718
|
+
checkDone();
|
|
719
|
+
resolve();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
// Also abort if client disconnects
|
|
723
|
+
streamAbort.signal.addEventListener("abort", () => {
|
|
724
|
+
cliProvider.interruptTurn(providerSessionId).catch(() => { });
|
|
725
|
+
resolve();
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
unsubscribe();
|
|
729
|
+
fullContent = contentChunks.join("");
|
|
730
|
+
// Flush any remaining tool group / trailing text into segments
|
|
731
|
+
flushToolGroup();
|
|
732
|
+
flushTextSegment();
|
|
733
|
+
// Build persistence JSON
|
|
734
|
+
const cliTcJson = cliToolCalls.length > 0 ? JSON.stringify(cliToolCalls) : undefined;
|
|
735
|
+
const cliSegJson = cliSegments.length > 0 ? JSON.stringify(cliSegments) : undefined;
|
|
736
|
+
// Also stash on the outer scope so the done handler can emit them
|
|
737
|
+
partialToolCalls = cliToolCalls;
|
|
738
|
+
resultSegmentsJson = cliSegJson;
|
|
739
|
+
// Persist assistant message with tool calls and segments
|
|
740
|
+
history.push({ role: "assistant", content: fullContent, uiToolCalls: cliToolCalls.length > 0 ? cliToolCalls : undefined });
|
|
741
|
+
persistMessage(sessionId, "assistant", fullContent, cliTcJson, cliSegJson);
|
|
742
|
+
// Session stays alive for the next turn — do NOT stop it.
|
|
743
|
+
// It will be cleaned up on session error, provider switch, or server shutdown.
|
|
744
|
+
}
|
|
745
|
+
else if (config.llmProvider === "openai") {
|
|
746
|
+
// ══ OpenAI agentic loop (using extracted runAgentLoop) ═════
|
|
747
|
+
// Build tiered schemas per request — respects user-disabled tools
|
|
748
|
+
const userSettings = userService?.getSettings(authUser.id);
|
|
749
|
+
const disabledTools = userSettings?.disabledTools?.length
|
|
750
|
+
? new Set(userSettings.disabledTools)
|
|
751
|
+
: undefined;
|
|
752
|
+
const toolSchemas = toolRegistry
|
|
753
|
+
? buildTieredToolSchemas(toolRegistry, disabledTools)
|
|
754
|
+
: [];
|
|
755
|
+
const onEvent = (event) => {
|
|
756
|
+
emitToSubscribers(sessionId, event);
|
|
757
|
+
safeWrite(`data: ${JSON.stringify(event)}\n\n`);
|
|
758
|
+
// ── Cross-client sync: persist & broadcast state changes ──
|
|
759
|
+
const ev = event;
|
|
760
|
+
// Broadcast todo list updates to all session clients and persist to DB
|
|
761
|
+
if (ev.type === "todo_list" && Array.isArray(ev.items)) {
|
|
762
|
+
if (sessionStateService) {
|
|
763
|
+
try {
|
|
764
|
+
sessionStateService.set(sessionId, { "todo_list": ev.items });
|
|
765
|
+
}
|
|
766
|
+
catch { /* ignore */ }
|
|
767
|
+
}
|
|
768
|
+
if (ws) {
|
|
769
|
+
ws.broadcast(sessionId, {
|
|
770
|
+
type: "ui.state-sync",
|
|
771
|
+
sessionId,
|
|
772
|
+
timestamp: new Date().toISOString(),
|
|
773
|
+
payload: { key: "todo_list", value: ev.items },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Broadcast file change events and persist cumulative list
|
|
778
|
+
if (ev.type === "file_changed" && typeof ev.path === "string") {
|
|
779
|
+
if (ws) {
|
|
780
|
+
ws.broadcast(sessionId, {
|
|
781
|
+
type: "ui.state-sync",
|
|
782
|
+
sessionId,
|
|
783
|
+
timestamp: new Date().toISOString(),
|
|
784
|
+
payload: { key: "file_changed", value: { path: ev.path, name: ev.name } },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
// Persist cumulative changed files list
|
|
788
|
+
if (sessionStateService) {
|
|
789
|
+
try {
|
|
790
|
+
const existing = sessionStateService.get(sessionId, ["changed_files"]);
|
|
791
|
+
const files = Array.isArray(existing["changed_files"]) ? existing["changed_files"] : [];
|
|
792
|
+
if (!files.some((f) => f.path === ev.path)) {
|
|
793
|
+
files.push({ path: ev.path, name: ev.name ?? "" });
|
|
794
|
+
sessionStateService.set(sessionId, { "changed_files": files });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch { /* ignore */ }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
const result = await runAgentLoop({
|
|
802
|
+
llm: llmRuntime,
|
|
803
|
+
history,
|
|
804
|
+
toolSchemas,
|
|
805
|
+
hasTools,
|
|
806
|
+
sessionId,
|
|
807
|
+
auth: { userId: authUser.id, apiKeys: userApiKeys },
|
|
808
|
+
abort: streamAbort,
|
|
809
|
+
maxRounds: MAX_TOOL_ROUNDS,
|
|
810
|
+
parallel: true,
|
|
811
|
+
toolRegistry,
|
|
812
|
+
disabledTools,
|
|
813
|
+
mode: chatMode,
|
|
814
|
+
onEvent,
|
|
815
|
+
onPersist: (sid, role, content, tc, seg) => persistMessage(sid, role, content, tc, seg),
|
|
816
|
+
log: app.log,
|
|
817
|
+
}, executeTool, steering);
|
|
818
|
+
fullContent = result.content;
|
|
819
|
+
partialToolCalls = result.executedToolCalls;
|
|
820
|
+
resultSegmentsJson = result.segments.length > 0 ? JSON.stringify(result.segments) : undefined;
|
|
821
|
+
hitMaxRounds = result.hitMaxRounds;
|
|
822
|
+
// Track executed tool calls for retry API
|
|
823
|
+
sessionExecutedToolCalls.set(sessionId, result.executedToolCalls);
|
|
824
|
+
// Store plan if plan mode produced one
|
|
825
|
+
if (result.plan) {
|
|
826
|
+
sessionPlans.set(sessionId, result.plan);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
// ══ Ollama (text only — no tool support) ═══════════════════
|
|
831
|
+
fullContent = await runOllamaStream(config, history, sessionId, streamAbort, safeWrite, app);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
// The OpenAI agentic loop now handles AbortError internally and returns
|
|
836
|
+
// partial results. This catch only fires for non-abort errors (OpenAI)
|
|
837
|
+
// or for Ollama stream errors (including abort).
|
|
838
|
+
const wasCancelled = err instanceof Error && err.name === "AbortError";
|
|
839
|
+
if (!wasCancelled)
|
|
840
|
+
app.log.error(err, `${providerLabel} streaming error`);
|
|
841
|
+
// Save partial content for real (non-cancel) errors
|
|
842
|
+
if (!wasCancelled && (fullContent || partialToolCalls.length > 0)) {
|
|
843
|
+
const tcJson = partialToolCalls.length > 0 ? JSON.stringify(partialToolCalls) : undefined;
|
|
844
|
+
persistMessage(sessionId, "assistant", fullContent || "", tcJson, resultSegmentsJson);
|
|
845
|
+
}
|
|
846
|
+
const errMsg = wasCancelled
|
|
847
|
+
? "cancelled"
|
|
848
|
+
: err instanceof Error ? err.message : `Failed to reach ${providerLabel}`;
|
|
849
|
+
emitToSubscribers(sessionId, wasCancelled
|
|
850
|
+
? { type: "done", session_id: sessionId, prompt_count: history.filter(m => m.role === "user").length, remaining_prompts: null }
|
|
851
|
+
: { type: "error", message: errMsg });
|
|
852
|
+
try {
|
|
853
|
+
safeWrite(`data: ${JSON.stringify(wasCancelled ? { type: "done", session_id: sessionId } : { type: "error", message: errMsg })}\n\n`);
|
|
854
|
+
}
|
|
855
|
+
catch { /* client gone */ }
|
|
856
|
+
}
|
|
857
|
+
// Persist partial results BEFORE clearing stream state so that a reload
|
|
858
|
+
// between these two steps loads the cancelled tool calls from the DB.
|
|
859
|
+
if (streamAbort.signal.aborted && partialToolCalls.length > 0) {
|
|
860
|
+
const tcJson = JSON.stringify(partialToolCalls);
|
|
861
|
+
persistMessage(sessionId, "assistant", fullContent || "", tcJson, resultSegmentsJson);
|
|
862
|
+
}
|
|
863
|
+
activeStreams.delete(sessionId);
|
|
864
|
+
sessionAbortControllers.delete(sessionId);
|
|
865
|
+
sessionSteeringControllers.delete(sessionId);
|
|
866
|
+
// Clean up in-memory history: remove any dangling assistant tool_calls
|
|
867
|
+
// messages that never got a text response (e.g. cancelled mid-tool-call).
|
|
868
|
+
// This prevents them from showing as "running" on reload.
|
|
869
|
+
const currentHistory = sessionHistory.get(sessionId);
|
|
870
|
+
if (currentHistory) {
|
|
871
|
+
// Walk backwards: if the last messages are assistant+tool_calls with no
|
|
872
|
+
// following text response, and the corresponding tool results are missing,
|
|
873
|
+
// remove them so the history is clean for the next session load.
|
|
874
|
+
while (currentHistory.length > 0) {
|
|
875
|
+
const last = currentHistory[currentHistory.length - 1];
|
|
876
|
+
// Remove orphaned tool result messages at the tail
|
|
877
|
+
if (last.role === "tool") {
|
|
878
|
+
currentHistory.pop();
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
// Remove assistant messages that only contain tool_calls with no text
|
|
882
|
+
if (last.role === "assistant" && last.tool_calls && !last.content) {
|
|
883
|
+
currentHistory.pop();
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Final done event
|
|
890
|
+
const doneEvent = {
|
|
891
|
+
type: "done",
|
|
892
|
+
session_id: sessionId,
|
|
893
|
+
prompt_count: history.filter(m => m.role === "user").length,
|
|
894
|
+
remaining_prompts: null,
|
|
895
|
+
hit_max_rounds: hitMaxRounds,
|
|
896
|
+
};
|
|
897
|
+
emitToSubscribers(sessionId, doneEvent);
|
|
898
|
+
safeWrite(`data: ${JSON.stringify(doneEvent)}\n\n`);
|
|
899
|
+
try {
|
|
900
|
+
reply.raw.end();
|
|
901
|
+
}
|
|
902
|
+
catch { /* already closed */ }
|
|
903
|
+
// Notify all WS-subscribed clients that the chat is done so they can refresh.
|
|
904
|
+
if (ws) {
|
|
905
|
+
ws.broadcast(sessionId, {
|
|
906
|
+
type: "message.complete",
|
|
907
|
+
sessionId,
|
|
908
|
+
timestamp: new Date().toISOString(),
|
|
909
|
+
payload: {},
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
// Cancel an active stream for a session
|
|
914
|
+
app.post("/api/sessions/:sessionId/cancel", async (request, reply) => {
|
|
915
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
916
|
+
if (!authUser)
|
|
917
|
+
return;
|
|
918
|
+
const { sessionId } = request.params;
|
|
919
|
+
if (sessionService) {
|
|
920
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
921
|
+
if (!session) {
|
|
922
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const controller = sessionAbortControllers.get(sessionId);
|
|
926
|
+
if (controller) {
|
|
927
|
+
controller.abort();
|
|
928
|
+
return { ok: true, cancelled: true };
|
|
929
|
+
}
|
|
930
|
+
return { ok: true, cancelled: false };
|
|
931
|
+
});
|
|
932
|
+
// Truncate a session from a specific user message onward (used for edit + replay).
|
|
933
|
+
app.post("/api/sessions/:sessionId/restart-from", async (request, reply) => {
|
|
934
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
935
|
+
if (!authUser)
|
|
936
|
+
return;
|
|
937
|
+
const { sessionId } = request.params;
|
|
938
|
+
if (sessionService) {
|
|
939
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
940
|
+
if (!session) {
|
|
941
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const body = request.body ?? {};
|
|
945
|
+
const messageId = typeof body["messageId"] === "string" ? body["messageId"] : "";
|
|
946
|
+
const messageIndex = typeof body["messageIndex"] === "number" ? body["messageIndex"] : -1;
|
|
947
|
+
const messageFromEnd = typeof body["messageFromEnd"] === "number" ? body["messageFromEnd"] : -1;
|
|
948
|
+
if (!messageId && messageIndex < 0 && messageFromEnd < 0) {
|
|
949
|
+
return reply.status(400).send({ error: "VALIDATION_ERROR", details: "messageId, messageFromEnd, or messageIndex is required" });
|
|
950
|
+
}
|
|
951
|
+
if (activeStreams.has(sessionId)) {
|
|
952
|
+
const controller = sessionAbortControllers.get(sessionId);
|
|
953
|
+
if (controller)
|
|
954
|
+
controller.abort();
|
|
955
|
+
const deadline = Date.now() + 5000;
|
|
956
|
+
while (activeStreams.has(sessionId) && Date.now() < deadline) {
|
|
957
|
+
await sleep(50);
|
|
958
|
+
}
|
|
959
|
+
if (activeStreams.has(sessionId)) {
|
|
960
|
+
return reply.status(409).send({ error: "CONFLICT", details: "Cannot restart while session is streaming" });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
hydrateSession(sessionId);
|
|
964
|
+
const history = sessionHistory.get(sessionId) ?? [];
|
|
965
|
+
const visibleEntries = buildVisibleHistoryEntries(sessionId, history);
|
|
966
|
+
let targetVisibleIndex = visibleEntries.findIndex((m) => m.id === messageId);
|
|
967
|
+
if (targetVisibleIndex === -1 &&
|
|
968
|
+
Number.isFinite(messageFromEnd) &&
|
|
969
|
+
messageFromEnd >= 0 &&
|
|
970
|
+
messageFromEnd < visibleEntries.length) {
|
|
971
|
+
targetVisibleIndex = visibleEntries.length - 1 - Math.floor(messageFromEnd);
|
|
972
|
+
}
|
|
973
|
+
if (targetVisibleIndex === -1 &&
|
|
974
|
+
Number.isFinite(messageIndex) &&
|
|
975
|
+
messageIndex >= 0 &&
|
|
976
|
+
messageIndex < visibleEntries.length) {
|
|
977
|
+
targetVisibleIndex = Math.floor(messageIndex);
|
|
978
|
+
}
|
|
979
|
+
if (targetVisibleIndex === -1) {
|
|
980
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Message not found" });
|
|
981
|
+
}
|
|
982
|
+
const target = visibleEntries[targetVisibleIndex];
|
|
983
|
+
if (target.role !== "user") {
|
|
984
|
+
return reply.status(400).send({ error: "VALIDATION_ERROR", details: "Only user messages can be edited/restarted" });
|
|
985
|
+
}
|
|
986
|
+
if (memoryService) {
|
|
987
|
+
const toFlush = visibleEntries
|
|
988
|
+
.slice(targetVisibleIndex)
|
|
989
|
+
.filter((entry) => entry.content.trim().length > 0)
|
|
990
|
+
.map((entry) => `[${entry.role}] ${entry.content}`);
|
|
991
|
+
await memoryService.flushPreCompaction(sessionId, toFlush);
|
|
992
|
+
}
|
|
993
|
+
const truncatedHistory = history.slice(0, target.historyIndex);
|
|
994
|
+
sessionHistory.set(sessionId, truncatedHistory);
|
|
995
|
+
if (db) {
|
|
996
|
+
const rows = db
|
|
997
|
+
.select()
|
|
998
|
+
.from(messagesTable)
|
|
999
|
+
.where(eq(messagesTable.sessionId, sessionId))
|
|
1000
|
+
.orderBy(messagesTable.createdAt)
|
|
1001
|
+
.all();
|
|
1002
|
+
const rowsToDelete = rows.slice(targetVisibleIndex);
|
|
1003
|
+
for (const row of rowsToDelete) {
|
|
1004
|
+
db.delete(messagesTable).where(eq(messagesTable.id, row.id)).run();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
sessionService?.touch(sessionId);
|
|
1009
|
+
}
|
|
1010
|
+
catch { /* ignore */ }
|
|
1011
|
+
const updatedMessages = buildVisibleHistoryMessages(sessionId, truncatedHistory);
|
|
1012
|
+
const windowed = windowMessages(updatedMessages, DEFAULT_UI_MESSAGE_LIMIT);
|
|
1013
|
+
return {
|
|
1014
|
+
ok: true,
|
|
1015
|
+
sessionId,
|
|
1016
|
+
streaming: false,
|
|
1017
|
+
total: windowed.total,
|
|
1018
|
+
hasMore: windowed.hasMore,
|
|
1019
|
+
limit: DEFAULT_UI_MESSAGE_LIMIT,
|
|
1020
|
+
messages: windowed.messages,
|
|
1021
|
+
};
|
|
1022
|
+
});
|
|
1023
|
+
// List messages in a session
|
|
1024
|
+
app.get("/api/sessions/:sessionId/messages", async (request, reply) => {
|
|
1025
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1026
|
+
if (!authUser)
|
|
1027
|
+
return;
|
|
1028
|
+
const { sessionId } = request.params;
|
|
1029
|
+
if (sessionService) {
|
|
1030
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1031
|
+
if (!session) {
|
|
1032
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const query = request.query;
|
|
1036
|
+
const limit = parseMessageLimit(query?.limit);
|
|
1037
|
+
hydrateSession(sessionId);
|
|
1038
|
+
const history = sessionHistory.get(sessionId) ?? [];
|
|
1039
|
+
const visible = buildVisibleHistoryMessages(sessionId, history);
|
|
1040
|
+
const windowed = windowMessages(visible, limit);
|
|
1041
|
+
return {
|
|
1042
|
+
sessionId,
|
|
1043
|
+
streaming: activeStreams.has(sessionId),
|
|
1044
|
+
total: windowed.total,
|
|
1045
|
+
hasMore: windowed.hasMore,
|
|
1046
|
+
limit,
|
|
1047
|
+
messages: windowed.messages,
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
// SSE stream-resume: join an in-progress session's token stream
|
|
1051
|
+
// Client receives a snapshot of current content, then live tokens until done.
|
|
1052
|
+
app.get("/api/sessions/:sessionId/stream", async (request, reply) => {
|
|
1053
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1054
|
+
if (!authUser)
|
|
1055
|
+
return;
|
|
1056
|
+
const { sessionId } = request.params;
|
|
1057
|
+
if (sessionService) {
|
|
1058
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1059
|
+
if (!session) {
|
|
1060
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const query = request.query;
|
|
1064
|
+
const limit = parseMessageLimit(query?.limit);
|
|
1065
|
+
reply.raw.writeHead(200, {
|
|
1066
|
+
"Content-Type": "text/event-stream",
|
|
1067
|
+
"Cache-Control": "no-cache",
|
|
1068
|
+
Connection: "keep-alive",
|
|
1069
|
+
});
|
|
1070
|
+
hydrateSession(sessionId);
|
|
1071
|
+
const history = sessionHistory.get(sessionId) ?? [];
|
|
1072
|
+
const isStreaming = activeStreams.has(sessionId);
|
|
1073
|
+
// Build snapshot. While streaming, prefer in-memory history so partial assistant
|
|
1074
|
+
// content is visible immediately (DB persistence may lag until stream completion).
|
|
1075
|
+
let snapshotMessages;
|
|
1076
|
+
let total = 0;
|
|
1077
|
+
let hasMore = false;
|
|
1078
|
+
if (db && !isStreaming) {
|
|
1079
|
+
const rows = db
|
|
1080
|
+
.select()
|
|
1081
|
+
.from(messagesTable)
|
|
1082
|
+
.where(eq(messagesTable.sessionId, sessionId))
|
|
1083
|
+
.orderBy(messagesTable.createdAt)
|
|
1084
|
+
.all();
|
|
1085
|
+
const allMessages = rows
|
|
1086
|
+
.filter((r) => r.role === "user" || r.role === "assistant")
|
|
1087
|
+
.map((r, i) => {
|
|
1088
|
+
const msg = {
|
|
1089
|
+
id: `${sessionId}-${i}`,
|
|
1090
|
+
role: r.role,
|
|
1091
|
+
content: r.content,
|
|
1092
|
+
};
|
|
1093
|
+
if (r.toolCalls) {
|
|
1094
|
+
try {
|
|
1095
|
+
msg.toolCalls = JSON.parse(r.toolCalls);
|
|
1096
|
+
}
|
|
1097
|
+
catch { /* ignore */ }
|
|
1098
|
+
}
|
|
1099
|
+
if (r.segments) {
|
|
1100
|
+
try {
|
|
1101
|
+
msg.segments = JSON.parse(r.segments);
|
|
1102
|
+
}
|
|
1103
|
+
catch { /* ignore */ }
|
|
1104
|
+
}
|
|
1105
|
+
return msg;
|
|
1106
|
+
});
|
|
1107
|
+
const windowed = windowMessages(allMessages, limit);
|
|
1108
|
+
snapshotMessages = windowed.messages;
|
|
1109
|
+
total = windowed.total;
|
|
1110
|
+
hasMore = windowed.hasMore;
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
const allMessages = buildVisibleHistoryMessages(sessionId, history, { includePendingAssistantToolCalls: isStreaming });
|
|
1114
|
+
const windowed = windowMessages(allMessages, limit);
|
|
1115
|
+
snapshotMessages = windowed.messages;
|
|
1116
|
+
total = windowed.total;
|
|
1117
|
+
hasMore = windowed.hasMore;
|
|
1118
|
+
}
|
|
1119
|
+
reply.raw.write(`data: ${JSON.stringify({
|
|
1120
|
+
type: "snapshot",
|
|
1121
|
+
messages: snapshotMessages,
|
|
1122
|
+
streaming: isStreaming,
|
|
1123
|
+
total,
|
|
1124
|
+
hasMore,
|
|
1125
|
+
limit,
|
|
1126
|
+
})}\n\n`);
|
|
1127
|
+
if (!isStreaming) {
|
|
1128
|
+
// Not streaming — send done immediately
|
|
1129
|
+
reply.raw.write(`data: ${JSON.stringify({ type: "done", session_id: sessionId, prompt_count: history.filter(m => m.role === "user").length, remaining_prompts: null })}\n\n`);
|
|
1130
|
+
reply.raw.end();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
// Subscribe to live events
|
|
1134
|
+
let closed = false;
|
|
1135
|
+
let unsubscribe = () => { };
|
|
1136
|
+
const closeStream = () => {
|
|
1137
|
+
if (closed)
|
|
1138
|
+
return;
|
|
1139
|
+
closed = true;
|
|
1140
|
+
unsubscribe();
|
|
1141
|
+
try {
|
|
1142
|
+
reply.raw.end();
|
|
1143
|
+
}
|
|
1144
|
+
catch { /* already closed */ }
|
|
1145
|
+
};
|
|
1146
|
+
reply.raw.on("close", () => {
|
|
1147
|
+
closeStream();
|
|
1148
|
+
});
|
|
1149
|
+
unsubscribe = subscribe(sessionId, (event) => {
|
|
1150
|
+
if (closed)
|
|
1151
|
+
return;
|
|
1152
|
+
try {
|
|
1153
|
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1154
|
+
if (event.type === "done" || event.type === "error") {
|
|
1155
|
+
closeStream();
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
closeStream();
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
// Clean up subscription if client disconnects before stream finishes
|
|
1163
|
+
request.raw.on("close", () => {
|
|
1164
|
+
closeStream();
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
// ── POST /api/sessions/:sessionId/retry-tool ────────────────────────
|
|
1168
|
+
// Retry a specific failed tool call by its callId.
|
|
1169
|
+
app.post("/api/sessions/:sessionId/retry-tool", async (request, reply) => {
|
|
1170
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1171
|
+
if (!authUser)
|
|
1172
|
+
return;
|
|
1173
|
+
const { sessionId } = request.params;
|
|
1174
|
+
if (sessionService) {
|
|
1175
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1176
|
+
if (!session) {
|
|
1177
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const body = request.body ?? {};
|
|
1181
|
+
const callId = typeof body["callId"] === "string" ? body["callId"] : "";
|
|
1182
|
+
if (!callId) {
|
|
1183
|
+
return reply.status(400).send({ error: "VALIDATION_ERROR", details: "callId is required" });
|
|
1184
|
+
}
|
|
1185
|
+
// Cannot retry while a stream is active
|
|
1186
|
+
if (activeStreams.has(sessionId)) {
|
|
1187
|
+
return reply.status(409).send({ error: "CONFLICT", details: "Cannot retry while session is streaming" });
|
|
1188
|
+
}
|
|
1189
|
+
const executed = sessionExecutedToolCalls.get(sessionId);
|
|
1190
|
+
if (!executed) {
|
|
1191
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "No tool calls recorded for this session" });
|
|
1192
|
+
}
|
|
1193
|
+
const original = executed.find((tc) => tc.callId === callId);
|
|
1194
|
+
if (!original) {
|
|
1195
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: `Tool call ${callId} not found` });
|
|
1196
|
+
}
|
|
1197
|
+
hydrateSession(sessionId);
|
|
1198
|
+
const history = sessionHistory.get(sessionId);
|
|
1199
|
+
if (!history) {
|
|
1200
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session history not found" });
|
|
1201
|
+
}
|
|
1202
|
+
const userApiKeys = userService?.getSettings(authUser.id).apiKeys ?? {};
|
|
1203
|
+
const result = await retryToolCall(callId, history, executed, executeTool, sessionId, { userId: authUser.id, apiKeys: userApiKeys }, (event) => emitToSubscribers(sessionId, event));
|
|
1204
|
+
// Persist updated history entry
|
|
1205
|
+
if (db) {
|
|
1206
|
+
const tcJson = JSON.stringify(executed);
|
|
1207
|
+
// Find the last assistant message and update its tool calls
|
|
1208
|
+
const rows = db
|
|
1209
|
+
.select()
|
|
1210
|
+
.from(messagesTable)
|
|
1211
|
+
.where(eq(messagesTable.sessionId, sessionId))
|
|
1212
|
+
.orderBy(messagesTable.createdAt)
|
|
1213
|
+
.all();
|
|
1214
|
+
const lastAssistant = [...rows].reverse().find((r) => r.role === "assistant");
|
|
1215
|
+
if (lastAssistant) {
|
|
1216
|
+
db.update(messagesTable)
|
|
1217
|
+
.set({ toolCalls: tcJson })
|
|
1218
|
+
.where(eq(messagesTable.id, lastAssistant.id))
|
|
1219
|
+
.run();
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
ok: result.ok,
|
|
1224
|
+
callId,
|
|
1225
|
+
tool: original.tool,
|
|
1226
|
+
message: result.message,
|
|
1227
|
+
data: result.data,
|
|
1228
|
+
retryCount: original.retryCount,
|
|
1229
|
+
};
|
|
1230
|
+
});
|
|
1231
|
+
// ── POST /api/sessions/:sessionId/steer ─────────────────────────────
|
|
1232
|
+
// Inject a steering message into an active agent loop.
|
|
1233
|
+
app.post("/api/sessions/:sessionId/steer", async (request, reply) => {
|
|
1234
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1235
|
+
if (!authUser)
|
|
1236
|
+
return;
|
|
1237
|
+
const { sessionId } = request.params;
|
|
1238
|
+
if (sessionService) {
|
|
1239
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1240
|
+
if (!session) {
|
|
1241
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const body = request.body ?? {};
|
|
1245
|
+
const message = typeof body["message"] === "string" ? body["message"] : "";
|
|
1246
|
+
if (!message.trim()) {
|
|
1247
|
+
return reply.status(400).send({ error: "VALIDATION_ERROR", details: "message is required" });
|
|
1248
|
+
}
|
|
1249
|
+
if (!activeStreams.has(sessionId)) {
|
|
1250
|
+
return reply.status(409).send({ error: "CONFLICT", details: "No active stream for this session — steering only works during streaming" });
|
|
1251
|
+
}
|
|
1252
|
+
const controller = sessionSteeringControllers.get(sessionId);
|
|
1253
|
+
if (!controller) {
|
|
1254
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "No steering controller for this session" });
|
|
1255
|
+
}
|
|
1256
|
+
controller.steer(message);
|
|
1257
|
+
return { ok: true, steered: true };
|
|
1258
|
+
});
|
|
1259
|
+
// ══ GET /api/sessions/:sessionId/plan — Get pending plan ═══════════
|
|
1260
|
+
app.get("/api/sessions/:sessionId/plan", async (request, reply) => {
|
|
1261
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1262
|
+
if (!authUser)
|
|
1263
|
+
return;
|
|
1264
|
+
const { sessionId } = request.params;
|
|
1265
|
+
if (sessionService) {
|
|
1266
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1267
|
+
if (!session) {
|
|
1268
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const plan = sessionPlans.get(sessionId);
|
|
1272
|
+
if (!plan) {
|
|
1273
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "No pending plan for this session" });
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
plan_id: plan.id,
|
|
1277
|
+
summary: plan.summary,
|
|
1278
|
+
actions: plan.actions.map((a) => ({
|
|
1279
|
+
id: a.id,
|
|
1280
|
+
tool: a.tool,
|
|
1281
|
+
args: a.args,
|
|
1282
|
+
description: a.description,
|
|
1283
|
+
order: a.order,
|
|
1284
|
+
status: a.status,
|
|
1285
|
+
})),
|
|
1286
|
+
};
|
|
1287
|
+
});
|
|
1288
|
+
// ══ POST /api/sessions/:sessionId/plan/execute — Execute approved plan ═
|
|
1289
|
+
app.post("/api/sessions/:sessionId/plan/execute", async (request, reply) => {
|
|
1290
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1291
|
+
if (!authUser)
|
|
1292
|
+
return;
|
|
1293
|
+
const { sessionId } = request.params;
|
|
1294
|
+
if (sessionService) {
|
|
1295
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1296
|
+
if (!session) {
|
|
1297
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const plan = sessionPlans.get(sessionId);
|
|
1301
|
+
if (!plan) {
|
|
1302
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "No pending plan for this session" });
|
|
1303
|
+
}
|
|
1304
|
+
const body = request.body ?? {};
|
|
1305
|
+
// Optional: allow partial approval by specifying action IDs to execute
|
|
1306
|
+
const approvedActionIds = Array.isArray(body["action_ids"])
|
|
1307
|
+
? new Set(body["action_ids"].filter((id) => typeof id === "string"))
|
|
1308
|
+
: null;
|
|
1309
|
+
const userApiKeys = userService?.getSettings(authUser.id).apiKeys ?? {};
|
|
1310
|
+
// SSE headers for streaming plan execution
|
|
1311
|
+
reply.raw.writeHead(200, {
|
|
1312
|
+
"Content-Type": "text/event-stream",
|
|
1313
|
+
"Cache-Control": "no-cache",
|
|
1314
|
+
Connection: "keep-alive",
|
|
1315
|
+
});
|
|
1316
|
+
let clientDisconnected = false;
|
|
1317
|
+
reply.raw.on("close", () => { clientDisconnected = true; });
|
|
1318
|
+
const safeWrite = (data) => {
|
|
1319
|
+
if (!clientDisconnected) {
|
|
1320
|
+
try {
|
|
1321
|
+
reply.raw.write(data);
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
clientDisconnected = true;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
const executionResults = [];
|
|
1329
|
+
for (const action of plan.actions) {
|
|
1330
|
+
// Skip rejected or already-executed actions
|
|
1331
|
+
if (action.status === "rejected" || action.status === "executed")
|
|
1332
|
+
continue;
|
|
1333
|
+
// If partial approval, skip non-approved
|
|
1334
|
+
if (approvedActionIds && !approvedActionIds.has(action.id)) {
|
|
1335
|
+
action.status = "rejected";
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
action.status = "approved";
|
|
1339
|
+
safeWrite(`data: ${JSON.stringify({ type: "plan_action_start", id: action.id, tool: action.tool, order: action.order })}\n\n`);
|
|
1340
|
+
emitToSubscribers(sessionId, { type: "tool_start", tool: action.tool, args: action.args, call_id: action.id });
|
|
1341
|
+
try {
|
|
1342
|
+
const result = await executeTool(action.tool, action.args, sessionId, { userId: authUser.id, apiKeys: userApiKeys }, (chunk) => {
|
|
1343
|
+
safeWrite(`data: ${JSON.stringify({ type: "plan_action_output", id: action.id, content: chunk })}\n\n`);
|
|
1344
|
+
});
|
|
1345
|
+
action.status = result.ok ? "executed" : "failed";
|
|
1346
|
+
action.result = { ok: result.ok, message: result.message, data: result.data };
|
|
1347
|
+
executionResults.push({ id: action.id, tool: action.tool, ok: result.ok, message: result.message, data: result.data });
|
|
1348
|
+
safeWrite(`data: ${JSON.stringify({
|
|
1349
|
+
type: "plan_action_result",
|
|
1350
|
+
id: action.id,
|
|
1351
|
+
tool: action.tool,
|
|
1352
|
+
ok: result.ok,
|
|
1353
|
+
message: result.message,
|
|
1354
|
+
data: result.data,
|
|
1355
|
+
})}\n\n`);
|
|
1356
|
+
emitToSubscribers(sessionId, {
|
|
1357
|
+
type: "tool_result",
|
|
1358
|
+
call_id: action.id,
|
|
1359
|
+
tool: action.tool,
|
|
1360
|
+
ok: result.ok,
|
|
1361
|
+
message: result.message,
|
|
1362
|
+
data: result.data,
|
|
1363
|
+
});
|
|
1364
|
+
// Add to conversation history so the agent has context
|
|
1365
|
+
const history = sessionHistory.get(sessionId);
|
|
1366
|
+
if (history) {
|
|
1367
|
+
history.push({
|
|
1368
|
+
role: "tool",
|
|
1369
|
+
content: JSON.stringify({ ok: result.ok, message: result.message, data: result.data }),
|
|
1370
|
+
tool_call_id: action.id,
|
|
1371
|
+
name: action.tool,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
catch (err) {
|
|
1376
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1377
|
+
action.status = "failed";
|
|
1378
|
+
action.result = { ok: false, message };
|
|
1379
|
+
executionResults.push({ id: action.id, tool: action.tool, ok: false, message });
|
|
1380
|
+
safeWrite(`data: ${JSON.stringify({ type: "plan_action_result", id: action.id, tool: action.tool, ok: false, message })}\n\n`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// Plan fully executed — clean up
|
|
1384
|
+
const allDone = plan.actions.every((a) => a.status === "executed" || a.status === "rejected" || a.status === "failed");
|
|
1385
|
+
if (allDone) {
|
|
1386
|
+
sessionPlans.delete(sessionId);
|
|
1387
|
+
}
|
|
1388
|
+
const succeeded = executionResults.filter((r) => r.ok).length;
|
|
1389
|
+
const failed = executionResults.filter((r) => !r.ok).length;
|
|
1390
|
+
safeWrite(`data: ${JSON.stringify({
|
|
1391
|
+
type: "plan_execution_complete",
|
|
1392
|
+
plan_id: plan.id,
|
|
1393
|
+
total: executionResults.length,
|
|
1394
|
+
succeeded,
|
|
1395
|
+
failed,
|
|
1396
|
+
results: executionResults,
|
|
1397
|
+
})}\n\n`);
|
|
1398
|
+
reply.raw.end();
|
|
1399
|
+
});
|
|
1400
|
+
// ══ POST /api/sessions/:sessionId/plan/reject — Reject/discard plan ═
|
|
1401
|
+
app.post("/api/sessions/:sessionId/plan/reject", async (request, reply) => {
|
|
1402
|
+
const authUser = await requireAuth(request, reply, config.jwtSecret);
|
|
1403
|
+
if (!authUser)
|
|
1404
|
+
return;
|
|
1405
|
+
const { sessionId } = request.params;
|
|
1406
|
+
if (sessionService) {
|
|
1407
|
+
const session = sessionService.getById(sessionId, authUser.id);
|
|
1408
|
+
if (!session) {
|
|
1409
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
const plan = sessionPlans.get(sessionId);
|
|
1413
|
+
if (!plan) {
|
|
1414
|
+
return reply.status(404).send({ error: "NOT_FOUND", details: "No pending plan for this session" });
|
|
1415
|
+
}
|
|
1416
|
+
for (const action of plan.actions) {
|
|
1417
|
+
if (action.status === "pending")
|
|
1418
|
+
action.status = "rejected";
|
|
1419
|
+
}
|
|
1420
|
+
sessionPlans.delete(sessionId);
|
|
1421
|
+
// Add a system message so the agent knows the plan was rejected
|
|
1422
|
+
const history = sessionHistory.get(sessionId);
|
|
1423
|
+
if (history) {
|
|
1424
|
+
history.push({
|
|
1425
|
+
role: "system",
|
|
1426
|
+
content: "[PLAN REJECTED] The user rejected the proposed plan. Ask if they want to revise it or try a different approach.",
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return { ok: true, plan_id: plan.id, message: "Plan rejected and discarded." };
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1433
|
+
// Agent loop extracted to ../tools/agent-loop.ts
|
|
1434
|
+
// (runAgentLoop, parseOpenAIStream, serializeMessages, etc.)
|
|
1435
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1436
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1437
|
+
// Ollama streaming (text-only — no tool support)
|
|
1438
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1439
|
+
async function runOllamaStream(config, history, sessionId, streamAbort, safeWrite, app) {
|
|
1440
|
+
let fullContent = "";
|
|
1441
|
+
const ollamaResponse = await fetch(`${config.ollamaUrl}/api/chat`, {
|
|
1442
|
+
method: "POST",
|
|
1443
|
+
headers: { "Content-Type": "application/json" },
|
|
1444
|
+
body: JSON.stringify({
|
|
1445
|
+
model: config.ollamaModel,
|
|
1446
|
+
messages: history
|
|
1447
|
+
.filter(m => m.role !== "tool")
|
|
1448
|
+
.map(m => ({ role: m.role, content: m.content })),
|
|
1449
|
+
stream: true,
|
|
1450
|
+
}),
|
|
1451
|
+
signal: streamAbort.signal,
|
|
1452
|
+
});
|
|
1453
|
+
if (!ollamaResponse.ok) {
|
|
1454
|
+
const errText = await ollamaResponse.text();
|
|
1455
|
+
app.log.error(`Ollama error ${ollamaResponse.status}: ${errText}`);
|
|
1456
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: `Ollama error: ${ollamaResponse.status}` })}\n\n`);
|
|
1457
|
+
return fullContent;
|
|
1458
|
+
}
|
|
1459
|
+
const reader = ollamaResponse.body?.getReader();
|
|
1460
|
+
if (!reader) {
|
|
1461
|
+
safeWrite(`data: ${JSON.stringify({ type: "error", message: "No response body from Ollama" })}\n\n`);
|
|
1462
|
+
return fullContent;
|
|
1463
|
+
}
|
|
1464
|
+
const decoder = new TextDecoder();
|
|
1465
|
+
let buffer = "";
|
|
1466
|
+
let streamingAssistantIndex = null;
|
|
1467
|
+
while (true) {
|
|
1468
|
+
const { done, value } = await reader.read();
|
|
1469
|
+
if (done)
|
|
1470
|
+
break;
|
|
1471
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1472
|
+
const lines = buffer.split("\n");
|
|
1473
|
+
buffer = lines.pop() || "";
|
|
1474
|
+
for (const line of lines) {
|
|
1475
|
+
if (!line.trim())
|
|
1476
|
+
continue;
|
|
1477
|
+
try {
|
|
1478
|
+
const chunk = JSON.parse(line);
|
|
1479
|
+
if (chunk.message?.content) {
|
|
1480
|
+
const token = chunk.message.content;
|
|
1481
|
+
fullContent += token;
|
|
1482
|
+
// Keep in-memory history updated during streaming so endpoints can
|
|
1483
|
+
// return a partial assistant response mid-stream.
|
|
1484
|
+
if (streamingAssistantIndex === null) {
|
|
1485
|
+
history.push({ role: "assistant", content: "" });
|
|
1486
|
+
streamingAssistantIndex = history.length - 1;
|
|
1487
|
+
}
|
|
1488
|
+
history[streamingAssistantIndex].content += token;
|
|
1489
|
+
emitToSubscribers(sessionId, { type: "token", content: token });
|
|
1490
|
+
safeWrite(`data: ${JSON.stringify({ type: "token", content: token })}\n\n`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
catch {
|
|
1494
|
+
// partial JSON
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if (fullContent) {
|
|
1499
|
+
persistMessageGlobal(sessionId, "assistant", fullContent);
|
|
1500
|
+
}
|
|
1501
|
+
return fullContent;
|
|
1502
|
+
}
|
|
1503
|
+
//# sourceMappingURL=chat.js.map
|