From e6738b30ecc17ce46ff5a889c61c17db973fd6ba Mon Sep 17 00:00:00 2001 From: minorcell Date: Tue, 3 Mar 2026 01:55:57 +0800 Subject: [PATCH 1/2] refactor: #196 --- README.md | 7 +- README.zh.md | 7 +- docs/core.md | 4 +- docs/npm-distribution-design.md | 6 +- docs/token-counting.md | 15 +- package.json | 18 +- packages/core/README.md | 4 +- packages/core/src/index.ts | 1 + packages/core/src/runtime/defaults.ts | 252 +- .../defaults.with_default_deps.test.ts | 270 +- packages/core/src/runtime/history_index.ts | 18 +- packages/core/src/server/handler/auth.test.ts | 34 + packages/core/src/server/handler/auth.ts | 122 + .../src/server/handler/session_manager.ts | 724 +++ packages/core/src/server/handler/workspace.ts | 215 + packages/core/src/server/http_server.test.ts | 246 + packages/core/src/server/http_server.ts | 169 + packages/core/src/server/index.ts | 9 + packages/core/src/server/router/api_routes.ts | 632 +++ .../core/src/server/router/http_router.ts | 98 + packages/core/src/server/router/openapi.ts | 131 + packages/core/src/server/utils/http.ts | 290 + packages/core/src/server/utils/sse.ts | 122 + packages/core/src/utils/tokenizer.ts | 118 +- packages/core/src/web/types.ts | 35 + packages/core/tsup.config.ts | 10 +- packages/tui/src/App.tsx | 44 +- packages/tui/src/cli.tsx | 23 +- packages/tui/src/http/core_server_client.ts | 330 ++ packages/tui/src/http/http_agent_session.ts | 546 ++ packages/tui/src/web/run_web_command.test.ts | 102 + packages/tui/src/web/run_web_command.ts | 171 +- packages/web-server/.gitignore | 56 - packages/web-server/.prettierrc | 4 - packages/web-server/eslint.config.mjs | 35 - packages/web-server/nest-cli.json | 8 - packages/web-server/package.json | 58 - packages/web-server/src/app.controller.ts | 14 - packages/web-server/src/app.module.ts | 50 - .../web-server/src/auth/access-token.guard.ts | 47 - .../web-server/src/auth/auth.controller.ts | 49 - packages/web-server/src/auth/auth.module.ts | 12 - packages/web-server/src/auth/auth.service.ts | 168 - packages/web-server/src/auth/auth.types.ts | 31 - .../web-server/src/auth/public.decorator.ts | 4 - .../web-server/src/chat/chat.controller.ts | 119 - packages/web-server/src/chat/chat.module.ts | 14 - packages/web-server/src/chat/chat.service.ts | 1259 ----- packages/web-server/src/chat/chat.types.ts | 51 - packages/web-server/src/common/constants.ts | 1 - .../src/common/filters/api-error.filter.ts | 116 - .../interceptors/api-response.interceptor.ts | 72 - .../middleware/request-logging.middleware.ts | 45 - .../src/config/memo-config.service.ts | 123 - .../src/config/memo-config.types.ts | 15 - .../src/config/server-config.module.ts | 10 - .../src/config/server-config.service.test.ts | 244 - .../src/config/server-config.service.ts | 422 -- .../src/config/server-config.types.ts | 31 - packages/web-server/src/main.ts | 18 - packages/web-server/src/mcp/mcp.controller.ts | 87 - packages/web-server/src/mcp/mcp.module.ts | 10 - packages/web-server/src/mcp/mcp.service.ts | 68 - packages/web-server/src/server.ts | 188 - .../src/sessions/sessions.controller.ts | 25 - .../src/sessions/sessions.module.ts | 12 - .../src/sessions/sessions.service.ts | 257 - .../web-server/src/sessions/sessions.types.ts | 25 - .../src/skills/skills.controller.ts | 61 - .../web-server/src/skills/skills.module.ts | 12 - .../web-server/src/skills/skills.service.ts | 127 - .../web-server/src/stream/stream.module.ts | 8 - .../web-server/src/stream/stream.service.ts | 199 - .../src/workspaces/workspaces.module.ts | 8 - .../src/workspaces/workspaces.service.ts | 430 -- .../src/workspaces/workspaces.types.ts | 6 - .../src/workspaces/workspaces.utils.ts | 7 - .../src/ws/rpc-router.service.test.ts | 244 - .../web-server/src/ws/rpc-router.service.ts | 314 -- .../ws/session-runtime-registry.service.ts | 142 - .../web-server/src/ws/ws-event-bus.service.ts | 18 - .../web-server/src/ws/ws-gateway.module.ts | 30 - .../web-server/src/ws/ws-gateway.service.ts | 707 --- packages/web-server/src/ws/ws.errors.ts | 13 - packages/web-server/src/ws/ws.types.ts | 40 - packages/web-server/tsconfig.build.json | 4 - packages/web-server/tsconfig.json | 29 - packages/web-ui/package.json | 1 - packages/web-ui/src/api/auth.ts | 22 +- packages/web-ui/src/api/chat.ts | 143 +- packages/web-ui/src/api/index.ts | 3 - packages/web-ui/src/api/mcp.ts | 48 +- packages/web-ui/src/api/request.ts | 143 +- packages/web-ui/src/api/sessions.ts | 39 +- packages/web-ui/src/api/skills.ts | 41 +- packages/web-ui/src/api/types.ts | 55 +- packages/web-ui/src/api/workspaces.ts | 30 +- packages/web-ui/src/api/ws-client.ts | 372 -- packages/web-ui/src/layouts/app-layout.tsx | 40 +- .../chat/components/chat-input-panel.tsx | 10 +- .../pages/chat/components/chat-timeline.tsx | 2 +- .../chat/components/markdown-message.tsx | 2 - packages/web-ui/src/pages/chat/index.tsx | 2 +- packages/web-ui/src/pages/login-page.tsx | 18 +- .../src/pages/settings/settings-account.tsx | 7 +- packages/web-ui/src/pages/skills-page.tsx | 2 - packages/web-ui/src/stores/auth-store.ts | 20 +- packages/web-ui/src/stores/chat-store.ts | 917 ++-- packages/web-ui/src/stores/skills-store.ts | 19 +- pnpm-lock.yaml | 4705 +---------------- .../components/memo-architecture-remotion.tsx | 2 +- site/content/blog/en/web-websocket-design.mdx | 158 +- site/content/blog/en/why-memo-web-version.mdx | 13 +- site/content/blog/zh/web-websocket-design.mdx | 158 +- site/content/blog/zh/why-memo-web-version.mdx | 13 +- tsup.config.ts | 46 +- 116 files changed, 5479 insertions(+), 12472 deletions(-) create mode 100644 packages/core/src/server/handler/auth.test.ts create mode 100644 packages/core/src/server/handler/auth.ts create mode 100644 packages/core/src/server/handler/session_manager.ts create mode 100644 packages/core/src/server/handler/workspace.ts create mode 100644 packages/core/src/server/http_server.test.ts create mode 100644 packages/core/src/server/http_server.ts create mode 100644 packages/core/src/server/index.ts create mode 100644 packages/core/src/server/router/api_routes.ts create mode 100644 packages/core/src/server/router/http_router.ts create mode 100644 packages/core/src/server/router/openapi.ts create mode 100644 packages/core/src/server/utils/http.ts create mode 100644 packages/core/src/server/utils/sse.ts create mode 100644 packages/tui/src/http/core_server_client.ts create mode 100644 packages/tui/src/http/http_agent_session.ts create mode 100644 packages/tui/src/web/run_web_command.test.ts delete mode 100644 packages/web-server/.gitignore delete mode 100644 packages/web-server/.prettierrc delete mode 100644 packages/web-server/eslint.config.mjs delete mode 100644 packages/web-server/nest-cli.json delete mode 100644 packages/web-server/package.json delete mode 100644 packages/web-server/src/app.controller.ts delete mode 100644 packages/web-server/src/app.module.ts delete mode 100644 packages/web-server/src/auth/access-token.guard.ts delete mode 100644 packages/web-server/src/auth/auth.controller.ts delete mode 100644 packages/web-server/src/auth/auth.module.ts delete mode 100644 packages/web-server/src/auth/auth.service.ts delete mode 100644 packages/web-server/src/auth/auth.types.ts delete mode 100644 packages/web-server/src/auth/public.decorator.ts delete mode 100644 packages/web-server/src/chat/chat.controller.ts delete mode 100644 packages/web-server/src/chat/chat.module.ts delete mode 100644 packages/web-server/src/chat/chat.service.ts delete mode 100644 packages/web-server/src/chat/chat.types.ts delete mode 100644 packages/web-server/src/common/constants.ts delete mode 100644 packages/web-server/src/common/filters/api-error.filter.ts delete mode 100644 packages/web-server/src/common/interceptors/api-response.interceptor.ts delete mode 100644 packages/web-server/src/common/middleware/request-logging.middleware.ts delete mode 100644 packages/web-server/src/config/memo-config.service.ts delete mode 100644 packages/web-server/src/config/memo-config.types.ts delete mode 100644 packages/web-server/src/config/server-config.module.ts delete mode 100644 packages/web-server/src/config/server-config.service.test.ts delete mode 100644 packages/web-server/src/config/server-config.service.ts delete mode 100644 packages/web-server/src/config/server-config.types.ts delete mode 100644 packages/web-server/src/main.ts delete mode 100644 packages/web-server/src/mcp/mcp.controller.ts delete mode 100644 packages/web-server/src/mcp/mcp.module.ts delete mode 100644 packages/web-server/src/mcp/mcp.service.ts delete mode 100644 packages/web-server/src/server.ts delete mode 100644 packages/web-server/src/sessions/sessions.controller.ts delete mode 100644 packages/web-server/src/sessions/sessions.module.ts delete mode 100644 packages/web-server/src/sessions/sessions.service.ts delete mode 100644 packages/web-server/src/sessions/sessions.types.ts delete mode 100644 packages/web-server/src/skills/skills.controller.ts delete mode 100644 packages/web-server/src/skills/skills.module.ts delete mode 100644 packages/web-server/src/skills/skills.service.ts delete mode 100644 packages/web-server/src/stream/stream.module.ts delete mode 100644 packages/web-server/src/stream/stream.service.ts delete mode 100644 packages/web-server/src/workspaces/workspaces.module.ts delete mode 100644 packages/web-server/src/workspaces/workspaces.service.ts delete mode 100644 packages/web-server/src/workspaces/workspaces.types.ts delete mode 100644 packages/web-server/src/workspaces/workspaces.utils.ts delete mode 100644 packages/web-server/src/ws/rpc-router.service.test.ts delete mode 100644 packages/web-server/src/ws/rpc-router.service.ts delete mode 100644 packages/web-server/src/ws/session-runtime-registry.service.ts delete mode 100644 packages/web-server/src/ws/ws-event-bus.service.ts delete mode 100644 packages/web-server/src/ws/ws-gateway.module.ts delete mode 100644 packages/web-server/src/ws/ws-gateway.service.ts delete mode 100644 packages/web-server/src/ws/ws.errors.ts delete mode 100644 packages/web-server/src/ws/ws.types.ts delete mode 100644 packages/web-server/tsconfig.build.json delete mode 100644 packages/web-server/tsconfig.json delete mode 100644 packages/web-ui/src/api/ws-client.ts diff --git a/README.md b/README.md index 4682f76..7af4c1e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ First run will guide you through Provider/Model setup and save config to `~/.mem | Resume Session | `memo --prev` | Load latest session for current directory | | Web Console | `memo web --host 127.0.0.1 --port 5494 --open` | Browser-based operation | +`memo web` requires a shared password via `MEMO_SERVER_PASSWORD`. + --- ## 🏗️ Architecture @@ -104,11 +106,10 @@ First run will guide you through Provider/Model setup and save config to `~/.mem ``` memo-code/ ├── packages/ -│ ├── core/ # Core logic: Session state machine, Config handling +│ ├── core/ # Core logic: Session state machine, Config handling, HTTP server API │ ├── tools/ # Tool routing, MCP Client management, built-in tools (exec_command, read_text_file, apply_patch...) │ ├── tui/ # Terminal runtime: CLI entry, interactive TUI │ ├── web-ui/ # Web frontend: React components -│ └── web-server/ # Web backend: session management, API adapter └── docs/ # Technical documentation ``` @@ -117,7 +118,7 @@ memo-code/ - **Architecture**: Clean Core / Tools / TUI separation, state-machine driven session management - **Testing**: Core + Tools coverage > 70%, complete unit + integration tests - **Protocol**: Native MCP (Model Context Protocol) support, can integrate any MCP tool server -- **Token Estimation**: Real-time context monitoring based on tiktoken, configurable auto-compaction strategy +- **Token Estimation**: Real-time context monitoring based on AI SDK usage + local fallback estimator, configurable auto-compaction strategy - **Distribution**: npm package with pre-built Web assets, hot-reloading without perception --- diff --git a/README.zh.md b/README.zh.md index f76e21f..8e0cc71 100644 --- a/README.zh.md +++ b/README.zh.md @@ -89,16 +89,17 @@ memo | 继续会话 | `memo --prev` | 加载当前目录的最新会话 | | Web 控制台 | `memo web --host 127.0.0.1 --port 5494 --open` | 浏览器操作 | +`memo web` 需要通过 `MEMO_SERVER_PASSWORD` 提供共享密码。 + ## 🏗️ 架构设计 ``` memo-code/ ├── packages/ -│ ├── core/ # 核心逻辑:Session 状态机、Config 处理 +│ ├── core/ # 核心逻辑:Session 状态机、Config 处理、HTTP Server API │ ├── tools/ # Tool 路由、MCP Client管理、内置工具实现(exec_command, read_text_file, apply_patch...) │ ├── tui/ # 终端运行时:CLI 入口、交互式 TUI │ ├── web-ui/ # Web 前端:React 组件 -│ └── web-server/ # Web 后端:会话管理、API 适配器 └── docs/ # 技术文档 ``` @@ -107,7 +108,7 @@ memo-code/ - **架构**:清晰的 Core / Tools / TUI 分层,状态机驱动会话管理 - **测试**:Core + Tools 覆盖率 > 70%,完整的单元 + 集成测试 - **协议**:原生支持 MCP (Model Context Protocol),可接入任意 MCP 工具服务器 -- **Token 估算**:基于 tiktoken 的实时上下文监控,支持可配置的自动压缩策略 +- **Token 估算**:基于 AI SDK usage + 本地估算器的实时上下文监控,支持可配置的自动压缩策略 - **分发**:npm 包预构建 Web 资源,热加载无感知 ## 🔧 内置工具 diff --git a/docs/core.md b/docs/core.md index f917098..ae016dc 100644 --- a/docs/core.md +++ b/docs/core.md @@ -20,7 +20,7 @@ Core should stay UI-agnostic: do not add Ink/UI rendering details into `packages - `session.ts`: Session/Turn state machine; runs ReAct loop, writes events, tracks tokens, fires hooks; **supports concurrent tool calls**. - `toolRouter/`: tool routing and management - `index.ts`: manages built-in + MCP tools, generates Tool Use API tool definitions. -- `utils/`: parsing and tokenizer wrappers (assistant output parsing, message wrappers, tiktoken wrapper). +- `utils/`: parsing and tokenizer wrappers (assistant output parsing, message wrappers, lightweight token estimator). - `types.ts`: shared types (**extended for Tool Use API support**). - `index.ts`: package entry exporting the modules above. @@ -74,7 +74,7 @@ if (toolUseBlocks.length > 1) { ## Entry API: Session/Turn (`createAgentSession`) - `createAgentSession(deps, options)` returns a Session; `runTurn` runs one ReAct turn. UI controls turn count. -- Default deps can be omitted: `tools` (built-in set), `callLLM` (provider-based OpenAI client, **auto-sends tool definitions**), `loadPrompt`, `historySinks` (writes to `~/.memo/sessions/...`), `tokenCounter`. +- Default deps can be omitted: `tools` (built-in set), `callLLM` (AI SDK Gateway-based client, **auto-sends tool definitions**), `loadPrompt`, `historySinks` (writes to `~/.memo/sessions/...`), `tokenCounter`. - Config source: `~/.memo/config.toml` (overridable via `MEMO_HOME`), keys include `current_provider` and `providers` list. Missing config triggers interactive UI setup. - Callbacks: - `onAssistantStep` (stream-like output) diff --git a/docs/npm-distribution-design.md b/docs/npm-distribution-design.md index 247cb92..fafe020 100644 --- a/docs/npm-distribution-design.md +++ b/docs/npm-distribution-design.md @@ -76,8 +76,8 @@ packages/core/src/ │ │ | --------------- | ------------- | -------------------------------- | | `react`, `ink` | bundle inline | required at runtime | | `fast-glob` | bundle inline | avoid user-side install concerns | -| `openai` | bundle inline | API client | -| `tiktoken` | bundle inline | token counting | +| `openai` | bundle inline | compatibility type/runtime deps | +| `ai` | bundle inline | AI SDK runtime | | `zod` | bundle inline | schema validation | | Node built-ins | `external` | provided by Node.js | @@ -276,7 +276,7 @@ memo --doctor ### 9.1 Possible Optimizations -- **Code splitting**: lazy-load large dependencies (for example tiktoken wasm) +- **Code splitting**: lazy-load large optional dependencies when needed - **Compression**: use Brotli to reduce package size further - **Incremental updates**: support hot-update style mechanism diff --git a/docs/token-counting.md b/docs/token-counting.md index 6f714b4..7616208 100644 --- a/docs/token-counting.md +++ b/docs/token-counting.md @@ -4,28 +4,29 @@ This document describes how Memo Code CLI estimates and records tokens for promp ## Counting Implementation -- **Underlying encoder**: uses `@dqbd/tiktoken`, default encoding `cl100k_base`; override via `tokenizerModel`. -- **Plain text count**: `countText(text)` encodes a string directly and returns token length. +- **Primary source**: prefers model-returned usage (`inputTokens/outputTokens/totalTokens`) from AI SDK calls. +- **Fallback counter**: local lightweight estimator (default model label `cl100k_base`); override via `tokenizerModel`. +- **Plain text count**: `countText(text)` estimates by character mix (ASCII/CJK/symbol/newline weighting). - **Message array count (ChatML approximation)**: `countMessages(messages)` uses a common OpenAI ChatML estimate: - fixed overhead of 4 tokens per message (role/name wrappers, etc.) - - `content` counted via tiktoken encoding + - `content` counted with local estimator - if `name` is supported later, adds 1 token - adds 2 tokens at the end for assistant priming -This is closer to actual ChatML overhead than naive text concatenation, but still an approximation. +This remains an approximation for context budgeting, while runtime accounting prefers provider usage when available. ## Usage Scenarios - **Prompt budgeting**: before each step, `runTurn` estimates prompt tokens with `countMessages` and applies: - `warnPromptTokens`: prints warning - `maxPromptTokens`: returns early when exceeded, preventing over-limit LLM requests -- **Usage reconciliation**: each step combines local count and model-returned `usage` (if available), records into token usage and JSONL history events. +- **Usage reconciliation**: each step combines local estimate and AI SDK usage (if available), records into token usage and JSONL history events. ## Precision and Limitations - Fixed ChatML overhead varies slightly by model. Current "4 per message + 2 ending" estimate may differ by dozens of tokens on specific models. -- Extra structural overhead for tool/function calling is not explicitly modeled yet. For exact reconciliation, model-specific constants can be added later. -- If using custom `callLLM`, pass matching model encoding or custom `tokenCounter` implementation to align with real usage. +- Extra structural overhead for tool/function calling is not explicitly modeled yet. For exact reconciliation, prefer provider usage data. +- If using custom `callLLM`, inject custom `tokenCounter` when you need model-specific estimation rules. ## How to Override diff --git a/package.json b/package.json index cd1fd33..4e1c8c1 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,12 @@ }, "scripts": { "start": "tsx packages/tui/src/cli.tsx", - "build": "pnpm run web:build && tsup", + "build": "pnpm run web:ui:build && tsup", "dev": "tsup --watch", - "web:dev": "pnpm -r --parallel --stream --filter @memo-code/web-server --filter @memo-code/web-ui dev", - "web:server:dev": "pnpm --filter @memo-code/web-server dev", + "web:dev": "pnpm -r --parallel --stream --filter @memo-code/web-ui dev", "web:ui:dev": "pnpm --filter @memo-code/web-ui dev", "web:ui:build": "pnpm --filter @memo-code/web-ui build", - "web:server:build": "pnpm --filter @memo-code/web-server build", - "web:build": "pnpm run web:ui:build && pnpm run web:server:build", + "web:build": "pnpm run web:ui:build", "site:dev": "pnpm --filter @memo-code/site dev", "site:build": "pnpm --filter @memo-code/site build", "site:start": "pnpm --filter @memo-code/site start", @@ -57,14 +55,11 @@ "vitest": "^2.1.8" }, "dependencies": { - "@dqbd/tiktoken": "^1.0.22", + "@ai-sdk/openai": "^3.0.37", "@inkjs/ui": "^2.0.0", "@modelcontextprotocol/sdk": "^1.24.3", "@mozilla/readability": "^0.6.0", - "@nestjs/common": "^11.0.1", - "@nestjs/core": "^11.0.1", - "@nestjs/jwt": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", + "ai": "^6.0.105", "fast-glob": "^3.3.3", "ink": "^6.7.0", "ipaddr.js": "^2.3.0", @@ -73,14 +68,11 @@ "openai": "^6.10.0", "react": "^19.2.4", "react-reconciler": "^0.33.0", - "reflect-metadata": "^0.2.2", "robots-parser": "^3.0.1", - "rxjs": "^7.8.1", "string-width": "^7.2.0", "toml": "^3.0.0", "turndown": "^7.2.2", "undici": "^6.23.0", - "ws": "^8.18.3", "yaml": "^2.8.1", "zod": "^4.3.6", "zod-to-json-schema": "^3.25.1" diff --git a/packages/core/README.md b/packages/core/README.md index 70a05c8..a61d545 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -14,13 +14,13 @@ Core provides the central capabilities of **Memo Code CLI**: the ReAct loop, ses - `types.ts`: Shared types (`AgentDeps`, `Session/Turn`, `TokenUsage`, `HistoryEvent`, etc.). - `utils/` - Utility functions (assistant output parsing, message wrappers). - - `tokenizer.ts`: tiktoken-based tokenizer helpers. + - `tokenizer.ts`: lightweight token estimation helpers (fallback with AI SDK usage). - `index.ts`: Package entry, exports core modules and types. ## Key Flows - `createAgentSession(deps, options)`: Creates a Session, fills default dependencies, loads prompt, and returns an object with `runTurn`. -- `withDefaultDeps`: Injects default toolset, LLM client, prompt, history sink (writes to `~/.memo/sessions/YY/MM/DD/.jsonl`), and tokenizer based on config and overrides. +- `withDefaultDeps`: Injects default toolset, AI SDK Gateway client, prompt, history sink (writes to `~/.memo/sessions/YY/MM/DD/.jsonl`), and tokenizer based on config and overrides. - Session history: JSONL events (`session_start/turn_start/assistant/action/observation/final/turn_end/session_end`) with metadata like provider, model, tokenizer, and token usage. - Config: `~/.memo/config.toml` (overridable via `MEMO_HOME`). If missing, UI setup flow is triggered. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d8ff4c..180a2db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,4 @@ export * from './utils/utils' export * from './utils/tokenizer' export * from './runtime/session' export * from './web/types' +export * from './server/http_server' diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/defaults.ts index 79e71a8..ec07468 100644 --- a/packages/core/src/runtime/defaults.ts +++ b/packages/core/src/runtime/defaults.ts @@ -1,6 +1,7 @@ /** @file Session default dependency assembly: toolset, LLM, history sinks, tokenizer, etc. */ import { NATIVE_TOOLS } from '@memo/tools' -import OpenAI from 'openai' +import { createOpenAI } from '@ai-sdk/openai' +import { generateText, jsonSchema, tool, type ModelMessage } from 'ai' import { createTokenCounter } from '@memo/core/utils/tokenizer' import { buildSessionPath, @@ -9,7 +10,6 @@ import { selectProvider, } from '@memo/core/config/config' import { JsonlHistorySink } from '@memo/core/runtime/history' -import { buildChatCompletionRequest, resolveModelProfile } from '@memo/core/runtime/model_profile' import { loadSystemPrompt as defaultLoadPrompt } from '@memo/core/runtime/prompt' import { ToolRouter } from '@memo/tools/router' import type { @@ -20,6 +20,7 @@ import type { HistorySink, TokenCounter, ToolRegistry, + ToolDefinition, } from '@memo/core/types' import type { MCPServerConfig } from '@memo/core/config/config' @@ -52,52 +53,112 @@ export function parseToolArguments( } } -function toOpenAIMessage(message: ChatMessage): OpenAI.Chat.Completions.ChatCompletionMessageParam { +function toModelMessage(message: ChatMessage): ModelMessage { if (message.role === 'assistant') { - const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { - reasoning_content?: string - } = { - role: 'assistant', - content: message.content, - tool_calls: message.tool_calls?.map((toolCall) => ({ - id: toolCall.id, - type: toolCall.type, - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments, - }, - })), + const hasReasoning = + typeof message.reasoning_content === 'string' && + message.reasoning_content.trim().length > 0 + const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0 + if (!hasReasoning && !hasToolCalls) { + return { role: 'assistant', content: message.content } + } + + const content: Array< + | { type: 'text'; text: string } + | { type: 'reasoning'; text: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + > = [] + if (message.content) { + content.push({ type: 'text', text: message.content }) + } + if (hasReasoning && message.reasoning_content) { + content.push({ type: 'reasoning', text: message.reasoning_content }) } - if (message.reasoning_content) { - assistantMessage.reasoning_content = message.reasoning_content + if (message.tool_calls) { + for (const toolCall of message.tool_calls) { + const parsed = parseToolArguments(toolCall.function.arguments) + content.push({ + type: 'tool-call', + toolCallId: toolCall.id, + toolName: toolCall.function.name, + input: parsed.ok ? parsed.data : { raw: parsed.raw }, + }) + } } - return assistantMessage as OpenAI.Chat.Completions.ChatCompletionMessageParam + + return { role: 'assistant', content } } + if (message.role === 'tool') { return { role: 'tool', - content: message.content, - tool_call_id: message.tool_call_id, + content: [ + { + type: 'tool-result', + toolCallId: message.tool_call_id, + toolName: message.name?.trim() || 'unknown_tool', + output: { + type: 'text', + value: message.content, + }, + }, + ], } } - return { - role: message.role, - content: message.content, + + return { role: message.role, content: message.content } +} + +function resolveProviderApiKey(envApiKeyName: string): string { + const value = + process.env[envApiKeyName] ?? process.env.OPENAI_API_KEY ?? process.env.DEEPSEEK_API_KEY + + if (!value) { + throw new Error(`Missing env var ${envApiKeyName} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`) } + return value +} + +function resolveProviderModelId(model: string): string { + return model.trim() } -function extractReasoningContent( - message: OpenAI.Chat.Completions.ChatCompletionMessage | undefined, -): string | undefined { - const raw = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content - if (typeof raw !== 'string') return undefined - const trimmed = raw.trim() - return trimmed.length > 0 ? trimmed : undefined +function toGenerateTextTools(toolDefinitions: ToolDefinition[]) { + if (toolDefinitions.length === 0) return undefined + + const entries = toolDefinitions.map((definition) => { + const inputSchema = + definition.input_schema && + typeof definition.input_schema === 'object' && + !Array.isArray(definition.input_schema) + ? definition.input_schema + : { type: 'object' } + + return [ + definition.name, + tool({ + description: definition.description, + inputSchema: jsonSchema(inputSchema as Record), + }), + ] as const + }) + return Object.fromEntries(entries) +} + +function normalizeReasoning(text: string | undefined): string | undefined { + const trimmed = text?.trim() + return trimmed ? trimmed : undefined } -function isChatCompletionResponse(value: unknown): value is OpenAI.Chat.Completions.ChatCompletion { - if (!value || typeof value !== 'object') return false - return Array.isArray((value as { choices?: unknown }).choices) +function mapFinishReasonToStopReason( + finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other', + toolCallCount: number, +): 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' { + if (toolCallCount > 0 || finishReason === 'tool-calls') { + return 'tool_use' + } + if (finishReason === 'length') return 'max_tokens' + return 'end_turn' } /** @@ -189,101 +250,54 @@ export async function withDefaultDeps( deps.callLLM ?? (async (messages, _onChunk, callOptions) => { const provider = selectProvider(config, options.providerName) - const apiKey = - process.env[provider.env_api_key] ?? - process.env.OPENAI_API_KEY ?? - process.env.DEEPSEEK_API_KEY - if (!apiKey) { - throw new Error( - `Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`, - ) - } - const client = new OpenAI({ + const apiKey = resolveProviderApiKey(provider.env_api_key) + const baseURL = provider.base_url?.trim() || undefined + const openaiProvider = createOpenAI({ apiKey, - baseURL: provider.base_url, + ...(baseURL ? { baseURL } : {}), }) - const openAIMessages = messages.map(toOpenAIMessage) - const { profile: modelProfile } = resolveModelProfile( - provider, - config.model_profiles, - ) - + const modelId = resolveProviderModelId(provider.model) + const model = openaiProvider.chat(modelId) + const modelMessages = messages.map(toModelMessage) const effectiveToolDefinitions = callOptions?.tools ?? toolDefinitions - const request = buildChatCompletionRequest({ - model: provider.model, - messages: openAIMessages, - toolDefinitions: effectiveToolDefinitions, - profile: modelProfile, + const generated = await generateText({ + model, + messages: modelMessages, + tools: toGenerateTextTools(effectiveToolDefinitions), + abortSignal: callOptions?.signal, }) - - const data = await client.chat.completions.create(request, { - signal: callOptions?.signal, - }) - if (!isChatCompletionResponse(data)) { - throw new Error('Streaming response is not supported in core callLLM') + const content: Array< + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: unknown } + > = [] + if (generated.text) { + _onChunk?.(generated.text) + content.push({ type: 'text', text: generated.text }) } - - const message = data.choices?.[0]?.message - const reasoningContent = extractReasoningContent(message) - - // 检查是否有工具调用 - if (message?.tool_calls && message.tool_calls.length > 0) { - const content: Array< - | { type: 'text'; text: string } - | { type: 'tool_use'; id: string; name: string; input: unknown } - > = [] - - // 添加文本内容(如果有) - if (message.content) { - content.push({ type: 'text', text: message.content }) - } - - // 添加工具调用 - for (const toolCall of message.tool_calls) { - if (toolCall.type === 'function') { - const parsedArgs = parseToolArguments(toolCall.function.arguments) - if (parsedArgs.ok) { - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedArgs.data, - }) - } else { - content.push({ - type: 'text', - text: `[tool_use parse error] ${parsedArgs.error}; raw: ${parsedArgs.raw}`, - }) - } - } - } - - const hasToolUse = content.some((c) => c.type === 'tool_use') - return { - content, - reasoning_content: reasoningContent, - stop_reason: hasToolUse ? 'tool_use' : 'end_turn', - usage: { - prompt: data.usage?.prompt_tokens ?? undefined, - completion: data.usage?.completion_tokens ?? undefined, - total: data.usage?.total_tokens ?? undefined, - }, - } + for (const toolCall of generated.toolCalls) { + content.push({ + type: 'tool_use', + id: toolCall.toolCallId, + name: toolCall.toolName, + input: toolCall.input, + }) } - // 普通文本响应 - const content = message?.content - if (typeof content !== 'string') { - throw new Error('OpenAI-compatible API returned empty content') + if (content.length === 0) { + throw new Error('AI SDK returned empty content') } + return { - content: [{ type: 'text', text: content }], - reasoning_content: reasoningContent, - stop_reason: 'end_turn', + content, + reasoning_content: normalizeReasoning(generated.reasoningText), + stop_reason: mapFinishReasonToStopReason( + generated.finishReason, + generated.toolCalls.length, + ), usage: { - prompt: data.usage?.prompt_tokens ?? undefined, - completion: data.usage?.completion_tokens ?? undefined, - total: data.usage?.total_tokens ?? undefined, + prompt: generated.usage.inputTokens ?? undefined, + completion: generated.usage.outputTokens ?? undefined, + total: generated.usage.totalTokens ?? undefined, }, } }), diff --git a/packages/core/src/runtime/defaults.with_default_deps.test.ts b/packages/core/src/runtime/defaults.with_default_deps.test.ts index f430b1a..21e816f 100644 --- a/packages/core/src/runtime/defaults.with_default_deps.test.ts +++ b/packages/core/src/runtime/defaults.with_default_deps.test.ts @@ -3,6 +3,7 @@ import type { AgentSessionDeps, AgentSessionOptions, ChatMessage, + ToolDefinition, ToolRegistry, } from '@memo/core/types' import type { MCPServerConfig } from '@memo/core/config/config' @@ -38,7 +39,13 @@ const state = vi.hoisted(() => ({ sessionsDir: '/tmp/memo-sessions', sessionPath: '/tmp/memo-sessions/session-1.jsonl', toolDescriptions: '## Tools\n- mock_tool', - toolDefinitions: [{ type: 'function', function: { name: 'mock_tool', parameters: {} } }], + toolDefinitions: [ + { + name: 'mock_tool', + description: 'mock tool', + input_schema: { type: 'object', properties: {} }, + }, + ] as ToolDefinition[], registry: { mock_tool: { name: 'mock_tool', @@ -48,28 +55,25 @@ const state = vi.hoisted(() => ({ execute: async () => ({ content: [{ type: 'text', text: 'ok' }] }), } as Tool, } as ToolRegistry, - buildRequestCalls: [] as unknown[], loadMcpServersCalls: [] as unknown[], registerNativeToolsCalls: [] as unknown[], registerNativeToolCalls: [] as unknown[], - openaiCtorCalls: [] as unknown[], - openaiCreateCalls: [] as unknown[], + createOpenAICalls: [] as unknown[], + openAIModelCalls: [] as unknown[], + generateTextCalls: [] as unknown[], historySinkPaths: [] as string[], routerDisposed: 0, createTokenCounterCalls: [] as Array, promptText: 'SYSTEM_PROMPT', - openaiResponse: { - choices: [ - { - message: { - content: 'ok', - }, - }, - ], + generateTextResponse: { + text: 'ok', + reasoningText: undefined, + toolCalls: [], + finishReason: 'stop', usage: { - prompt_tokens: 11, - completion_tokens: 7, - total_tokens: 18, + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, }, } as Record, })) @@ -93,14 +97,6 @@ vi.mock('@memo/core/runtime/history', () => ({ }, })) -vi.mock('@memo/core/runtime/model_profile', () => ({ - resolveModelProfile: vi.fn(() => ({ profile: { supportsParallelToolCalls: true } })), - buildChatCompletionRequest: vi.fn((request: unknown) => { - state.buildRequestCalls.push(request) - return request - }), -})) - vi.mock('@memo/core/runtime/prompt', () => ({ loadSystemPrompt: vi.fn(async () => state.promptText), })) @@ -150,48 +146,49 @@ vi.mock('@memo/tools/router', () => ({ }, })) -vi.mock('openai', () => ({ - default: class OpenAI { - chat = { - completions: { - create: async (request: unknown, options: unknown) => { - state.openaiCreateCalls.push({ request, options }) - return state.openaiResponse - }, +vi.mock('@ai-sdk/openai', () => ({ + createOpenAI: vi.fn((options: unknown) => { + state.createOpenAICalls.push(options) + return { + chat: (modelId: string) => { + state.openAIModelCalls.push(modelId) + return { provider: 'openai-compatible', modelId } }, } + }), +})) - constructor(config: unknown) { - state.openaiCtorCalls.push(config) - } - }, +vi.mock('ai', () => ({ + generateText: vi.fn(async (request: unknown) => { + state.generateTextCalls.push(request) + return state.generateTextResponse + }), + jsonSchema: vi.fn((schema: unknown) => schema), + tool: vi.fn((definition: unknown) => definition), })) describe('withDefaultDeps (default path)', () => { beforeEach(() => { - state.buildRequestCalls = [] state.loadMcpServersCalls = [] state.registerNativeToolsCalls = [] state.registerNativeToolCalls = [] - state.openaiCtorCalls = [] - state.openaiCreateCalls = [] + state.createOpenAICalls = [] + state.openAIModelCalls = [] + state.generateTextCalls = [] state.historySinkPaths = [] state.routerDisposed = 0 state.createTokenCounterCalls = [] state.toolDescriptions = '## Tools\n- mock_tool' state.promptText = 'SYSTEM_PROMPT' - state.openaiResponse = { - choices: [ - { - message: { - content: 'ok', - }, - }, - ], + state.generateTextResponse = { + text: 'ok', + reasoningText: undefined, + toolCalls: [], + finishReason: 'stop', usage: { - prompt_tokens: 11, - completion_tokens: 7, - total_tokens: 18, + inputTokens: 11, + outputTokens: 7, + totalTokens: 18, }, } delete process.env.MOCK_API_KEY @@ -271,59 +268,62 @@ describe('withDefaultDeps (default path)', () => { ).rejects.toThrow('Missing env var MOCK_API_KEY') }) + test('uses provider env key and provider base_url', async () => { + process.env.MOCK_API_KEY = 'mock-provider-key' + const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3b') + + await resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage]) + expect(state.createOpenAICalls[0]).toEqual({ + apiKey: 'mock-provider-key', + baseURL: 'https://mock.local/v1', + }) + expect(state.openAIModelCalls[0]).toBe('mock-model') + }) + test('falls back to OPENAI_API_KEY when provider key is missing', async () => { process.env.OPENAI_API_KEY = 'openai-fallback-key' const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3b') + const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3c') await resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage]) - expect(state.openaiCtorCalls[0]).toEqual({ + expect(state.createOpenAICalls[0]).toEqual({ apiKey: 'openai-fallback-key', baseURL: 'https://mock.local/v1', }) }) - test('maps tool calls into tool_use blocks and keeps parse errors as text', async () => { + test('maps AI SDK tool calls into tool_use blocks', async () => { process.env.MOCK_API_KEY = 'test-key' const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - const callOptionsTools = [ - { type: 'function', function: { name: 'override', parameters: {} } }, + const callOptionsTools: ToolDefinition[] = [ + { + name: 'override', + description: 'override tool', + input_schema: { type: 'object', properties: {} }, + }, ] const signal = new AbortController().signal - state.openaiResponse = { - choices: [ + state.generateTextResponse = { + text: 'assistant text', + reasoningText: ' reasoned ', + toolCalls: [ { - message: { - content: 'assistant text', - reasoning_content: ' reasoned ', - tool_calls: [ - { - id: 'call-ok', - type: 'function', - function: { name: 'echo', arguments: '{"value":1}' }, - }, - { - id: 'call-bad', - type: 'function', - function: { name: 'echo', arguments: '{bad-json' }, - }, - { - id: 'call-skip', - type: 'other', - function: { name: 'ignored', arguments: '{}' }, - }, - ], - }, + toolCallId: 'call-ok', + toolName: 'echo', + input: { value: 1 }, }, ], + finishReason: 'tool-calls', usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, }, } + const chunks: string[] = [] const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-4') const response = await resolved.callLLM( [ @@ -347,7 +347,9 @@ describe('withDefaultDeps (default path)', () => { }, { role: 'user', content: 'continue' }, ], - undefined, + (chunk) => { + chunks.push(chunk) + }, { tools: callOptionsTools, signal }, ) @@ -361,84 +363,39 @@ describe('withDefaultDeps (default path)', () => { name: 'echo', input: { value: 1 }, }) - expect( - response.content.some( - (item) => - item.type === 'text' && - item.text.startsWith('[tool_use parse error]') && - item.text.includes('{bad-json'), - ), - ).toBe(true) - - expect(state.openaiCtorCalls[0]).toEqual({ - apiKey: 'test-key', - baseURL: 'https://mock.local/v1', - }) + expect(chunks).toEqual(['assistant text']) - expect(state.buildRequestCalls).toHaveLength(1) - const request = state.buildRequestCalls[0] as { - toolDefinitions: unknown[] + expect(state.generateTextCalls).toHaveLength(1) + const request = state.generateTextCalls[0] as { + abortSignal: AbortSignal messages: Array> + tools: Record } - expect(request.toolDefinitions).toEqual(callOptionsTools) - expect( - request.messages.some((msg) => msg.role === 'tool' && msg.tool_call_id === 'prev-call'), - ).toBe(true) + expect(request.abortSignal).toBe(signal) + expect(request.tools).toHaveProperty('override') expect( request.messages.some( - (msg) => msg.role === 'assistant' && msg.reasoning_content === 'reasoning content', + (msg) => + msg.role === 'tool' && + Array.isArray(msg.content) && + (msg.content[0] as { type?: string }).type === 'tool-result', ), ).toBe(true) - - expect(state.openaiCreateCalls).toHaveLength(1) - expect( - (state.openaiCreateCalls[0] as { options: { signal: AbortSignal } }).options.signal, - ).toBe(signal) - }) - - test('returns end_turn when tool_calls has no usable function calls', async () => { - process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - - state.openaiResponse = { - choices: [ - { - message: { - content: '', - tool_calls: [{ id: 'call-non-fn', type: 'other' }], - }, - }, - ], - usage: { - prompt_tokens: 1, - completion_tokens: 0, - total_tokens: 1, - }, - } - - const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-5') - const response = await resolved.callLLM([{ role: 'user', content: 'x' } as ChatMessage]) - expect(response.stop_reason).toBe('end_turn') - expect(response.content).toEqual([]) }) test('returns plain text end_turn response with usage', async () => { process.env.MOCK_API_KEY = 'test-key' const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - state.openaiResponse = { - choices: [ - { - message: { - content: 'plain assistant answer', - reasoning_content: ' concise reason ', - }, - }, - ], + state.generateTextResponse = { + text: 'plain assistant answer', + reasoningText: ' concise reason ', + toolCalls: [], + finishReason: 'stop', usage: { - prompt_tokens: 3, - completion_tokens: 4, - total_tokens: 7, + inputTokens: 3, + outputTokens: 4, + totalTokens: 7, }, } @@ -450,28 +407,25 @@ describe('withDefaultDeps (default path)', () => { expect(response.usage).toEqual({ prompt: 3, completion: 4, total: 7 }) }) - test('throws when provider returns non-string content without tool calls', async () => { + test('throws when AI SDK returns empty content', async () => { process.env.MOCK_API_KEY = 'test-key' const { withDefaultDeps } = await import('@memo/core/runtime/defaults') - state.openaiResponse = { - choices: [ - { - message: { - content: null, - }, - }, - ], + state.generateTextResponse = { + text: '', + reasoningText: undefined, + toolCalls: [], + finishReason: 'stop', usage: { - prompt_tokens: 1, - completion_tokens: 1, - total_tokens: 2, + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, }, } const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-6') await expect( resolved.callLLM([{ role: 'user', content: 'x' } as ChatMessage]), - ).rejects.toThrow('OpenAI-compatible API returned empty content') + ).rejects.toThrow('AI SDK returned empty content') }) }) diff --git a/packages/core/src/runtime/history_index.ts b/packages/core/src/runtime/history_index.ts index f20f5d8..8d3c12f 100644 --- a/packages/core/src/runtime/history_index.ts +++ b/packages/core/src/runtime/history_index.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, stat } from 'node:fs/promises' +import { readdir, readFile, stat, unlink } from 'node:fs/promises' import { join, resolve } from 'node:path' import type { SessionDetail, @@ -251,6 +251,22 @@ export class HistoryIndex { return Array.from(this.cache.values()).map((entry) => entry.summary) } + async removeSession(sessionId: string): Promise<{ deleted: boolean }> { + await this.refresh() + const path = this.sessionIdToPath.get(sessionId) + if (!path) return { deleted: false } + + try { + await unlink(path) + } catch { + // Best-effort: still drop stale cache state if file is already gone. + } + + this.sessionIdToPath.delete(sessionId) + this.cache.delete(path) + return { deleted: true } + } + private async refreshInternal(): Promise { const files = await walkSessionFiles(this.sessionsDir) const knownPaths = new Set(files.map((file) => file.filePath)) diff --git a/packages/core/src/server/handler/auth.test.ts b/packages/core/src/server/handler/auth.test.ts new file mode 100644 index 0000000..9cd621f --- /dev/null +++ b/packages/core/src/server/handler/auth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test, vi } from 'vitest' +import { CoreAuth, CoreAuthError } from '@memo/core/server/handler/auth' + +describe('CoreAuth', () => { + test('issues and verifies access token', () => { + const auth = new CoreAuth({ password: 'secret' }) + const login = auth.login('secret') + + expect(typeof login.accessToken).toBe('string') + expect(login.expiresIn).toBeGreaterThan(0) + + const payload = auth.verify(login.accessToken) + expect(payload.sub).toBe('memo-user') + expect(payload.exp).toBeGreaterThan(payload.iat) + }) + + test('throws on invalid password', () => { + const auth = new CoreAuth({ password: 'secret' }) + expect(() => auth.login('bad-password')).toThrow(CoreAuthError) + }) + + test('throws when token expired', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const auth = new CoreAuth({ password: 'secret', tokenTtlSeconds: 1 }) + const login = auth.login('secret') + + vi.setSystemTime(new Date('2026-01-01T00:00:05.000Z')) + expect(() => auth.verify(login.accessToken)).toThrow(CoreAuthError) + + vi.useRealTimers() + }) +}) diff --git a/packages/core/src/server/handler/auth.ts b/packages/core/src/server/handler/auth.ts new file mode 100644 index 0000000..66bf2e5 --- /dev/null +++ b/packages/core/src/server/handler/auth.ts @@ -0,0 +1,122 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' +import type { AuthLoginResponse } from '@memo/core/web/types' + +type TokenPayload = { + sub: string + iat: number + exp: number +} + +export type CoreAuthOptions = { + password: string + tokenTtlSeconds?: number + subject?: string +} + +export class CoreAuthError extends Error { + constructor( + readonly code: 'INVALID_CREDENTIALS' | 'TOKEN_INVALID' | 'TOKEN_EXPIRED', + message: string, + ) { + super(message) + } +} + +function toBase64Url(input: string | Buffer): string { + return Buffer.from(input) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +function fromBase64Url(input: string): Buffer { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/') + const padding = normalized.length % 4 + const padded = padding === 0 ? normalized : `${normalized}${'='.repeat(4 - padding)}` + return Buffer.from(padded, 'base64') +} + +function safeEqual(left: Buffer, right: Buffer): boolean { + if (left.length !== right.length) return false + return timingSafeEqual(left, right) +} + +function hashSecret(secret: string): Buffer { + return createHash('sha256').update(secret, 'utf8').digest() +} + +export class CoreAuth { + private readonly passwordHash: Buffer + private readonly signingKey: Buffer + private readonly tokenTtlSeconds: number + private readonly subject: string + + constructor(options: CoreAuthOptions) { + const password = options.password + if (!password) { + throw new Error('MEMO_SERVER_PASSWORD is required') + } + this.passwordHash = hashSecret(password) + this.signingKey = hashSecret(`memo-core-auth:${password}`) + this.tokenTtlSeconds = + typeof options.tokenTtlSeconds === 'number' && + Number.isFinite(options.tokenTtlSeconds) && + options.tokenTtlSeconds > 0 + ? Math.floor(options.tokenTtlSeconds) + : 8 * 60 * 60 + this.subject = options.subject?.trim() || 'memo-user' + } + + login(inputPassword: string): AuthLoginResponse { + const incoming = hashSecret(inputPassword) + if (!safeEqual(incoming, this.passwordHash)) { + throw new CoreAuthError('INVALID_CREDENTIALS', 'Invalid password') + } + + const iat = Math.floor(Date.now() / 1000) + const payload: TokenPayload = { + sub: this.subject, + iat, + exp: iat + this.tokenTtlSeconds, + } + + const payloadPart = toBase64Url(JSON.stringify(payload)) + const signaturePart = this.sign(payloadPart) + + return { + accessToken: `${payloadPart}.${signaturePart}`, + expiresIn: this.tokenTtlSeconds, + } + } + + verify(token: string): TokenPayload { + const [payloadPart, signaturePart] = token.split('.') + if (!payloadPart || !signaturePart) { + throw new CoreAuthError('TOKEN_INVALID', 'Invalid access token') + } + + const expectedSignature = this.sign(payloadPart) + if (!safeEqual(Buffer.from(signaturePart), Buffer.from(expectedSignature))) { + throw new CoreAuthError('TOKEN_INVALID', 'Invalid access token') + } + + let payload: TokenPayload + try { + payload = JSON.parse(fromBase64Url(payloadPart).toString('utf8')) as TokenPayload + } catch { + throw new CoreAuthError('TOKEN_INVALID', 'Invalid access token') + } + + const now = Math.floor(Date.now() / 1000) + if (!payload.exp || payload.exp <= now) { + throw new CoreAuthError('TOKEN_EXPIRED', 'Access token expired') + } + + return payload + } + + private sign(payloadPart: string): string { + return toBase64Url(createHmac('sha256', this.signingKey).update(payloadPart).digest()) + } +} diff --git a/packages/core/src/server/handler/session_manager.ts b/packages/core/src/server/handler/session_manager.ts new file mode 100644 index 0000000..4c04e4d --- /dev/null +++ b/packages/core/src/server/handler/session_manager.ts @@ -0,0 +1,724 @@ +import { randomUUID } from 'node:crypto' +import { join, resolve } from 'node:path' +import { + loadMemoConfig, + resolveContextWindowForProvider, + selectProvider, +} from '@memo/core/config/config' +import { HistoryIndex } from '@memo/core/runtime/history_index' +import { createAgentSession } from '@memo/core/runtime/session' +import { + defaultWorkspaceName, + normalizeWorkspacePath, + workspaceIdFromCwd, +} from '@memo/core/runtime/workspace' +import type { + AgentSession, + AgentSessionDeps, + ApprovalDecision, + ApprovalRequest, + ChatMessage, + ToolPermissionMode, +} from '@memo/core/types' +import type { + LiveSessionState, + QueuedInputItem, + SessionDetail, + SessionEventsResponse, + SessionListResponse, + SessionRuntimeBadge, +} from '@memo/core/web/types' +import type { SseHub } from '@memo/core/server/utils/sse' + +const DEFAULT_MAX_LIVE_SESSIONS = 20 +const DEFAULT_MAX_QUEUED_INPUTS = 5 + +export type CreateLiveSessionInput = { + sessionId?: string + providerName?: string + cwd?: string + toolPermissionMode?: ToolPermissionMode + activeMcpServers?: string[] +} + +export type SubmitMessageResult = { + accepted: boolean + queueId: string + queued: number +} + +export type QueueMutationResult = { + removed?: boolean + triggered?: boolean + queued: number +} + +type LiveSessionRuntime = { + id: string + title: string + workspaceId: string + projectName: string + providerName: string + model: string + cwd: string + startedAt: string + status: 'idle' | 'running' | 'closed' + pendingApproval?: { + fingerprint: string + toolName: string + reason: string + riskLevel: string + params: unknown + } + activeMcpServers: string[] + toolPermissionMode: ToolPermissionMode + queuedInputs: QueuedInputItem[] + currentContextTokens?: number + contextWindow?: number + historyFilePath?: string + availableToolNames?: string[] + pendingApprovals: Map void> + queueDraining: boolean + closed: boolean + currentTurn: number + agentSession: AgentSession +} + +function normalizeToolPermissionMode(input: unknown): ToolPermissionMode { + if (input === 'none' || input === 'once' || input === 'full') { + return input + } + return 'once' +} + +function parseActiveMcpServers(input: unknown): string[] { + if (!Array.isArray(input)) return [] + return Array.from( + new Set( + input + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + ), + ) +} + +export class CoreSessionManager { + private readonly sessions = new Map() + private historyIndex: HistoryIndex | null = null + + constructor( + private readonly options: { + sseHub: SseHub + memoHome?: string + maxLiveSessions?: number + maxQueuedInputs?: number + }, + ) {} + + async createSession(input: CreateLiveSessionInput): Promise { + const maxLiveSessions = this.options.maxLiveSessions ?? DEFAULT_MAX_LIVE_SESSIONS + if (this.sessions.size >= maxLiveSessions) { + throw new Error(`Too many live sessions (max=${maxLiveSessions})`) + } + + const loaded = await loadMemoConfig() + const provider = selectProvider(loaded.config, input.providerName) + const cwd = normalizeWorkspacePath(input.cwd?.trim() || process.cwd()) + const workspaceId = workspaceIdFromCwd(cwd) + const contextWindow = resolveContextWindowForProvider(loaded.config, provider) + const requestedMcpServers = parseActiveMcpServers(input.activeMcpServers) + const activeMcpServers = + requestedMcpServers.length > 0 + ? requestedMcpServers + : loaded.config.active_mcp_servers || [] + const toolPermissionMode = normalizeToolPermissionMode(input.toolPermissionMode) + const preferredSessionId = input.sessionId?.trim() + const id = preferredSessionId || randomUUID() + if (this.sessions.has(id)) { + throw new Error(`session already exists: ${id}`) + } + + let runtimeRef: LiveSessionRuntime | null = null + const deps: AgentSessionDeps = { + onAssistantStep: (content, step) => { + if (!content) return + const runtime = runtimeRef + if (!runtime) return + const turn = runtime.currentTurn || 1 + this.emit(runtime.id, 'assistant.chunk', { + turn, + step, + chunk: content, + }) + }, + hooks: { + onTurnStart: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.currentTurn = payload.turn + runtime.status = 'running' + this.emit(runtime.id, 'turn.start', { + turn: payload.turn, + input: payload.input, + promptTokens: payload.promptTokens, + }) + this.emit(runtime.id, 'session.status', { status: 'running' }) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + }, + onContextUsage: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.currentContextTokens = payload.promptTokens + runtime.contextWindow = payload.contextWindow + this.emit(runtime.id, 'context.usage', { + turn: payload.turn, + step: payload.step, + phase: payload.phase, + promptTokens: payload.promptTokens, + contextWindow: payload.contextWindow, + thresholdTokens: payload.thresholdTokens, + usagePercent: payload.usagePercent, + }) + }, + onContextCompacted: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.currentContextTokens = payload.afterTokens + this.emit(runtime.id, 'context.compact', { + turn: payload.turn, + step: payload.step, + reason: payload.reason, + status: payload.status, + beforeTokens: payload.beforeTokens, + afterTokens: payload.afterTokens, + thresholdTokens: payload.thresholdTokens, + reductionPercent: payload.reductionPercent, + summary: payload.summary, + errorMessage: payload.errorMessage, + }) + }, + onAction: (payload) => { + const runtime = runtimeRef + if (!runtime) return + this.emit(runtime.id, 'tool.action', { + turn: payload.turn, + step: payload.step, + action: payload.action, + parallelActions: payload.parallelActions, + thinking: payload.thinking, + }) + }, + onObservation: (payload) => { + const runtime = runtimeRef + if (!runtime) return + this.emit(runtime.id, 'tool.observation', { + turn: payload.turn, + step: payload.step, + observation: payload.observation, + resultStatus: payload.resultStatus, + parallelResultStatuses: payload.parallelResultStatuses, + }) + }, + onApprovalRequest: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.pendingApproval = { + fingerprint: payload.request.fingerprint, + toolName: payload.request.toolName, + reason: payload.request.reason, + riskLevel: payload.request.riskLevel, + params: payload.request.params, + } + this.emit(runtime.id, 'approval.request', runtime.pendingApproval) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + }, + onApprovalResponse: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.pendingApproval = undefined + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + this.emit(runtime.id, 'system.message', { + title: 'Approval response recorded', + content: `${payload.fingerprint}: ${payload.decision}`, + tone: 'info', + }) + }, + onFinal: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.pendingApproval = undefined + this.emit(runtime.id, 'turn.final', { + turn: payload.turn, + step: payload.step, + finalText: payload.finalText, + status: payload.status, + errorMessage: payload.errorMessage, + turnUsage: payload.turnUsage, + tokenUsage: payload.tokenUsage ?? payload.turnUsage, + }) + }, + onTitleGenerated: (payload) => { + const runtime = runtimeRef + if (!runtime) return + runtime.title = payload.title + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + }, + }, + requestApproval: async (request: ApprovalRequest): Promise => { + const runtime = runtimeRef + if (!runtime || runtime.closed) return 'deny' + return new Promise((resolveDecision) => { + runtime.pendingApprovals.set(request.fingerprint, resolveDecision) + }) + }, + } + + const agentSession = await createAgentSession(deps, { + sessionId: id, + cwd, + providerName: provider.name, + toolPermissionMode, + contextWindow, + activeMcpServers, + autoCompactThresholdPercent: loaded.config.auto_compact_threshold_percent, + }) + + runtimeRef = { + id, + title: 'New Session', + workspaceId, + projectName: defaultWorkspaceName(cwd), + providerName: provider.name, + model: provider.model, + cwd, + startedAt: new Date().toISOString(), + status: 'idle', + activeMcpServers, + toolPermissionMode, + queuedInputs: [], + currentContextTokens: 0, + contextWindow, + historyFilePath: agentSession.historyFilePath, + availableToolNames: agentSession.listToolNames?.() ?? [], + pendingApprovals: new Map(), + queueDraining: false, + closed: false, + currentTurn: 0, + agentSession, + } + + this.sessions.set(id, runtimeRef) + this.emit(id, 'session.snapshot', this.toLiveState(runtimeRef)) + + return this.toLiveState(runtimeRef) + } + + getSessionState(sessionId: string): LiveSessionState | null { + const session = this.sessions.get(sessionId) + if (!session) return null + return this.toLiveState(session) + } + + async closeSession(sessionId: string): Promise<{ removed: boolean }> { + const runtime = this.sessions.get(sessionId) + if (!runtime) return { removed: false } + + runtime.closed = true + runtime.status = 'closed' + runtime.queuedInputs = [] + + for (const resolver of runtime.pendingApprovals.values()) { + resolver('deny') + } + runtime.pendingApprovals.clear() + + await runtime.agentSession.close() + + this.emit(runtime.id, 'session.status', { status: 'closed' }) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + this.options.sseHub.closeSession(runtime.id) + + this.sessions.delete(runtime.id) + return { removed: true } + } + + async submitMessage(sessionId: string, input: string): Promise { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + const trimmed = input.trim() + if (!trimmed) { + throw new Error('input is required') + } + + const maxQueued = this.options.maxQueuedInputs ?? DEFAULT_MAX_QUEUED_INPUTS + if (runtime.queuedInputs.length >= maxQueued) { + throw new Error(`queue is full (max=${maxQueued})`) + } + + const queueId = randomUUID() + const queued: QueuedInputItem = { + id: queueId, + input: trimmed, + createdAt: new Date().toISOString(), + } + + runtime.queuedInputs.push(queued) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + void this.drainQueue(runtime) + + return { + accepted: true, + queueId, + queued: runtime.queuedInputs.length, + } + } + + removeQueuedInput(sessionId: string, queueId: string): QueueMutationResult { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + const before = runtime.queuedInputs.length + runtime.queuedInputs = runtime.queuedInputs.filter((item) => item.id !== queueId) + const removed = runtime.queuedInputs.length < before + + if (removed) { + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + } + + return { + removed, + queued: runtime.queuedInputs.length, + } + } + + sendQueuedInputNow(sessionId: string): QueueMutationResult { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + if (runtime.queuedInputs.length === 0) { + return { + triggered: false, + queued: 0, + } + } + + if (!runtime.queueDraining) { + void this.drainQueue(runtime) + } + + return { + triggered: true, + queued: runtime.queuedInputs.length, + } + } + + cancelTurn(sessionId: string): { cancelled: boolean } { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) return { cancelled: false } + + runtime.agentSession.cancelCurrentTurn?.('cancelled from HTTP API') + return { cancelled: true } + } + + async compactSession(sessionId: string): Promise<{ + reason: string + status: string + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string + keptMessages: number + }> { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + const result = await runtime.agentSession.compactHistory('manual') + runtime.currentContextTokens = result.afterTokens + this.emit(runtime.id, 'context.usage', { + turn: runtime.currentTurn, + step: 0, + phase: 'post_compact', + promptTokens: result.afterTokens, + contextWindow: runtime.contextWindow ?? 0, + thresholdTokens: result.thresholdTokens, + usagePercent: + result.afterTokens > 0 && runtime.contextWindow + ? Math.round((result.afterTokens / runtime.contextWindow) * 10_000) / 100 + : 0, + }) + + return { + reason: result.reason, + status: result.status, + beforeTokens: result.beforeTokens, + afterTokens: result.afterTokens, + thresholdTokens: result.thresholdTokens, + reductionPercent: result.reductionPercent, + summary: result.summary, + errorMessage: result.errorMessage, + keptMessages: runtime.agentSession.history.length, + } + } + + applyApprovalDecision( + sessionId: string, + fingerprint: string, + decision: ApprovalDecision, + ): { recorded: boolean } { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + const resolver = runtime.pendingApprovals.get(fingerprint) + if (!resolver) { + throw new Error(`approval not found: ${fingerprint}`) + } + + runtime.pendingApprovals.delete(fingerprint) + runtime.pendingApproval = undefined + resolver(decision) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + return { recorded: true } + } + + restoreHistory( + sessionId: string, + messages: unknown[], + ): { restored: boolean; messages: number } { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) { + throw new Error(`session not found: ${sessionId}`) + } + + const normalized = normalizeHistoryMessages(messages) + const system = runtime.agentSession.history[0] + if (!system || system.role !== 'system') { + throw new Error('session history is missing system prompt') + } + + runtime.agentSession.history.splice( + 0, + runtime.agentSession.history.length, + system, + ...normalized, + ) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + return { restored: true, messages: normalized.length } + } + + async listProviders(): Promise<{ + items: Array<{ name: string; model: string; env_api_key: string; base_url?: string }> + }> { + const loaded = await loadMemoConfig() + return { + items: loaded.config.providers.map((provider) => ({ + name: provider.name, + model: provider.model, + env_api_key: provider.env_api_key, + base_url: provider.base_url, + })), + } + } + + listRuntimeBadges(query?: { workspaceCwd?: string }): { items: SessionRuntimeBadge[] } { + const workspaceCwd = query?.workspaceCwd?.trim() + const targetWorkspaceId = workspaceCwd + ? workspaceIdFromCwd(normalizeWorkspacePath(workspaceCwd)) + : null + + const items = Array.from(this.sessions.values()) + .filter((runtime) => !runtime.closed) + .filter((runtime) => + targetWorkspaceId ? runtime.workspaceId === targetWorkspaceId : true, + ) + .map((runtime) => ({ + sessionId: runtime.id, + workspaceId: runtime.workspaceId, + status: runtime.status, + updatedAt: new Date().toISOString(), + })) + + return { items } + } + + async listSessions(query: { + page?: number + pageSize?: number + sortBy?: 'updatedAt' | 'startedAt' | 'project' | 'title' + order?: 'asc' | 'desc' + project?: string + workspaceCwd?: string + dateFrom?: string + dateTo?: string + q?: string + }): Promise { + const index = await this.getHistoryIndex() + return index.list(query) + } + + async getSessionDetail(sessionId: string): Promise { + const index = await this.getHistoryIndex() + return index.getSessionDetail(sessionId) + } + + async getSessionEvents( + sessionId: string, + cursor?: string, + limit?: number, + ): Promise { + const index = await this.getHistoryIndex() + return index.getSessionEvents(sessionId, cursor, limit) + } + + async removeSessionHistory(sessionId: string): Promise<{ deleted: boolean }> { + const index = await this.getHistoryIndex() + return index.removeSession(sessionId) + } + + resolveSessionCwd(sessionId: string): string | null { + const runtime = this.sessions.get(sessionId) + if (!runtime || runtime.closed) return null + return runtime.cwd + } + + async close(): Promise { + const ids = Array.from(this.sessions.keys()) + for (const id of ids) { + await this.closeSession(id) + } + } + + private async getHistoryIndex(): Promise { + if (this.historyIndex) return this.historyIndex + + const loaded = await loadMemoConfig() + const memoHome = this.options.memoHome ? resolve(this.options.memoHome) : loaded.home + this.historyIndex = new HistoryIndex({ + sessionsDir: join(memoHome, 'sessions'), + }) + return this.historyIndex + } + + private toLiveState(runtime: LiveSessionRuntime): LiveSessionState { + return { + id: runtime.id, + title: runtime.title, + workspaceId: runtime.workspaceId, + projectName: runtime.projectName, + providerName: runtime.providerName, + model: runtime.model, + cwd: runtime.cwd, + startedAt: runtime.startedAt, + status: runtime.status, + pendingApproval: runtime.pendingApproval, + activeMcpServers: runtime.activeMcpServers, + toolPermissionMode: runtime.toolPermissionMode, + queuedInputs: runtime.queuedInputs, + currentContextTokens: runtime.currentContextTokens, + contextWindow: runtime.contextWindow, + historyFilePath: runtime.historyFilePath, + availableToolNames: runtime.availableToolNames, + } + } + + private emit(sessionId: string, event: string, payload: unknown): void { + this.options.sseHub.publish(sessionId, event, payload) + } + + private async drainQueue(runtime: LiveSessionRuntime): Promise { + if (runtime.queueDraining || runtime.closed) return + runtime.queueDraining = true + + try { + while (!runtime.closed && runtime.queuedInputs.length > 0) { + const next = runtime.queuedInputs.shift() + if (!next) continue + + runtime.status = 'running' + this.emit(runtime.id, 'session.status', { status: 'running' }) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + + try { + await runtime.agentSession.runTurn(next.input) + } catch (error) { + this.emit(runtime.id, 'error', { + code: 'TURN_FAILED', + message: (error as Error).message, + }) + } finally { + runtime.status = runtime.closed ? 'closed' : 'idle' + this.emit(runtime.id, 'session.status', { + status: runtime.status, + }) + this.emit(runtime.id, 'session.snapshot', this.toLiveState(runtime)) + } + } + } finally { + runtime.queueDraining = false + } + } +} + +function normalizeHistoryMessages(messages: unknown[]): ChatMessage[] { + const normalized: ChatMessage[] = [] + for (const item of messages) { + if (!item || typeof item !== 'object') { + throw new Error('history messages must be objects') + } + + const role = (item as { role?: unknown }).role + const content = (item as { content?: unknown }).content + if (typeof content !== 'string') { + throw new Error('history message content must be string') + } + + if (role === 'user') { + normalized.push({ role: 'user', content }) + continue + } + + if (role === 'assistant') { + const reasoningContent = (item as { reasoning_content?: unknown }).reasoning_content + normalized.push({ + role: 'assistant', + content, + ...(typeof reasoningContent === 'string' + ? { reasoning_content: reasoningContent } + : {}), + }) + continue + } + + if (role === 'tool') { + const toolCallId = (item as { tool_call_id?: unknown }).tool_call_id + const name = (item as { name?: unknown }).name + if (typeof toolCallId !== 'string' || !toolCallId.trim()) { + throw new Error('tool message requires tool_call_id') + } + normalized.push({ + role: 'tool', + content, + tool_call_id: toolCallId, + ...(typeof name === 'string' ? { name } : {}), + }) + continue + } + + throw new Error(`unsupported history role: ${String(role)}`) + } + + return normalized +} diff --git a/packages/core/src/server/handler/workspace.ts b/packages/core/src/server/handler/workspace.ts new file mode 100644 index 0000000..5da019a --- /dev/null +++ b/packages/core/src/server/handler/workspace.ts @@ -0,0 +1,215 @@ +import { constants } from 'node:fs' +import { access, readdir, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, resolve } from 'node:path' +import { CoreSessionManager } from '@memo/core/server/handler/session_manager' +import { + defaultWorkspaceName, + normalizeWorkspacePath, + workspaceIdFromCwd, +} from '@memo/core/runtime/workspace' +import type { + WorkspaceDirEntry, + WorkspaceFsListResult, + WorkspaceRecord, +} from '@memo/core/web/types' +import { HttpApiError } from '@memo/core/server/utils/http' + +const MAX_DIRECTORY_ITEMS = 200 + +export type WorkspaceState = { + overrides: Map + removedIds: Set +} + +export function createWorkspaceState(): WorkspaceState { + return { + overrides: new Map(), + removedIds: new Set(), + } +} + +export function buildWorkspaceRecord(cwd: string, name?: string): WorkspaceRecord { + const normalized = normalizeWorkspacePath(cwd) + const now = new Date().toISOString() + return { + id: workspaceIdFromCwd(normalized), + name: name?.trim() || defaultWorkspaceName(normalized), + cwd: normalized, + createdAt: now, + lastUsedAt: now, + } +} + +export async function listWorkspaces( + sessionManager: CoreSessionManager, + state: WorkspaceState, +): Promise<{ items: WorkspaceRecord[] }> { + const listing = await sessionManager.listSessions({ + page: 1, + pageSize: 1000, + sortBy: 'updatedAt', + order: 'desc', + }) + + const byId = new Map() + for (const item of listing.items) { + const recordId = workspaceIdFromCwd(item.cwd) + const existing = byId.get(recordId) + if (!existing) { + byId.set(recordId, { + id: recordId, + name: item.project || defaultWorkspaceName(item.cwd), + cwd: normalizeWorkspacePath(item.cwd), + createdAt: item.date.startedAt, + lastUsedAt: item.date.updatedAt, + }) + continue + } + + if (item.date.startedAt < existing.createdAt) { + existing.createdAt = item.date.startedAt + } + if (item.date.updatedAt > existing.lastUsedAt) { + existing.lastUsedAt = item.date.updatedAt + } + } + + for (const [id, override] of state.overrides.entries()) { + byId.set(id, override) + } + for (const removedId of state.removedIds) { + byId.delete(removedId) + } + + const items = Array.from(byId.values()).sort((left, right) => + right.lastUsedAt.localeCompare(left.lastUsedAt), + ) + return { items } +} + +export async function listWorkspaceDirectories( + pathInput: string | null, +): Promise { + const rootPath = await resolveReadableDirectory('/') + let requestedPath = pathInput?.trim() ? pathInput.trim() : rootPath + + if (!pathInput?.trim() && rootPath === '/') { + try { + requestedPath = await resolveReadableDirectory(homedir()) + } catch { + requestedPath = rootPath + } + } + + const targetPath = await resolveReadableDirectory(requestedPath) + + if (!isWithinRoot(targetPath, rootPath)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'path is outside workspace browser root') + } + + let entries: import('node:fs').Dirent[] + try { + entries = await readdir(targetPath, { withFileTypes: true }) + } catch { + throw new HttpApiError(400, 'BAD_REQUEST', 'failed to read directory') + } + + const items: WorkspaceDirEntry[] = [] + const sorted = entries.sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of sorted) { + if (entry.name.startsWith('.')) continue + if (items.length >= MAX_DIRECTORY_ITEMS) break + + const full = resolve(targetPath, entry.name) + if (entry.isDirectory()) { + const normalized = normalizeWorkspacePath(full) + if (!isWithinRoot(normalized, rootPath)) continue + let readable = true + try { + await access(normalized, constants.R_OK | constants.X_OK) + } catch { + readable = false + } + items.push({ + name: entry.name, + path: normalized, + kind: 'dir', + readable, + }) + continue + } + + if (entry.isSymbolicLink()) { + try { + const linkedPath = normalizeWorkspacePath(await realpath(full)) + const linkedStat = await stat(linkedPath) + if (!linkedStat.isDirectory()) continue + if (!isWithinRoot(linkedPath, rootPath)) continue + let readable = true + try { + await access(linkedPath, constants.R_OK | constants.X_OK) + } catch { + readable = false + } + items.push({ + name: entry.name, + path: linkedPath, + kind: 'dir', + readable, + }) + } catch { + // Ignore unreadable symlink entries. + } + } + } + + const parent = dirname(targetPath) + const parentPath = + targetPath === rootPath || !isWithinRoot(parent, rootPath) + ? null + : normalizeWorkspacePath(parent) + + return { + path: normalizeWorkspacePath(targetPath), + parentPath, + items, + } +} + +function isWithinRoot(path: string, rootPath: string): boolean { + const normalizedPath = normalizeWorkspacePath(path) + const normalizedRoot = normalizeWorkspacePath(rootPath) + if (normalizedRoot === '/') return true + if (normalizedPath === normalizedRoot) return true + return normalizedPath.startsWith(`${normalizedRoot}/`) +} + +async function resolveReadableDirectory(path: string): Promise { + const normalizedPath = normalizeWorkspacePath(path) + let realPathValue: string + try { + realPathValue = normalizeWorkspacePath(await realpath(normalizedPath)) + } catch { + throw new HttpApiError(400, 'BAD_REQUEST', `directory does not exist: ${path}`) + } + + let directoryStat: import('node:fs').Stats + try { + directoryStat = await stat(realPathValue) + } catch { + throw new HttpApiError(400, 'BAD_REQUEST', `directory is not accessible: ${path}`) + } + + if (!directoryStat.isDirectory()) { + throw new HttpApiError(400, 'BAD_REQUEST', `path is not a directory: ${path}`) + } + + try { + await access(realPathValue, constants.R_OK | constants.X_OK) + } catch { + throw new HttpApiError(400, 'BAD_REQUEST', `directory is not readable: ${path}`) + } + + return realPathValue +} diff --git a/packages/core/src/server/http_server.test.ts b/packages/core/src/server/http_server.test.ts new file mode 100644 index 0000000..90be45c --- /dev/null +++ b/packages/core/src/server/http_server.test.ts @@ -0,0 +1,246 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'vitest' +import { startCoreHttpServer, stopCoreHttpServer } from '@memo/core/server/http_server' + +async function readJson(response: Response): Promise { + const text = await response.text() + return text ? (JSON.parse(text) as unknown) : null +} + +function normalizePath(value: string): string { + const normalized = value.replace(/\\/g, '/') + if (normalized === '/') return normalized + return normalized.replace(/\/+$/g, '') +} + +describe('startCoreHttpServer', () => { + afterEach(async () => { + await stopCoreHttpServer() + }) + + test('supports login, auth, and session creation APIs', async () => { + const handle = await startCoreHttpServer({ + host: '127.0.0.1', + port: 0, + password: 'test-password', + }) + + try { + const publicOpenapi = await fetch(`${handle.url}/api/openapi.json`) + expect(publicOpenapi.status).toBe(200) + + const login = await fetch(`${handle.url}/api/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ password: 'test-password' }), + }) + expect(login.status).toBe(200) + const loginBody = (await readJson(login)) as { + success: true + data: { accessToken: string } + } + expect(loginBody.success).toBe(true) + expect(typeof loginBody.data.accessToken).toBe('string') + + const token = loginBody.data.accessToken + + const openapi = await fetch(`${handle.url}/api/openapi.json`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + expect(openapi.status).toBe(200) + + const created = await fetch(`${handle.url}/api/chat/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ sessionId: 'session-http-test' }), + }) + expect(created.status).toBe(200) + const createdBody = (await readJson(created)) as { + success: true + data: { id: string } + } + expect(createdBody.success).toBe(true) + expect(typeof createdBody.data.id).toBe('string') + expect(createdBody.data.id).toBe('session-http-test') + + const runtimes = await fetch(`${handle.url}/api/chat/runtimes`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + expect(runtimes.status).toBe(200) + const runtimesBody = (await readJson(runtimes)) as { + success: true + data: { items: Array<{ sessionId: string }> } + } + expect(runtimesBody.success).toBe(true) + expect( + runtimesBody.data.items.some((item) => item.sessionId === createdBody.data.id), + ).toBe(true) + + const sendNow = await fetch( + `${handle.url}/api/chat/sessions/${createdBody.data.id}/queue/send_now`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }, + ) + expect(sendNow.status).toBe(200) + const sendNowBody = (await readJson(sendNow)) as { + success: true + data: { triggered: boolean; queued: number } + } + expect(sendNowBody.success).toBe(true) + expect(sendNowBody.data.triggered).toBe(false) + + const removeQueue = await fetch( + `${handle.url}/api/chat/sessions/${createdBody.data.id}/queue/non-existent`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + expect(removeQueue.status).toBe(200) + const removeQueueBody = (await readJson(removeQueue)) as { + success: true + data: { removed: boolean; queued: number } + } + expect(removeQueueBody.success).toBe(true) + expect(removeQueueBody.data.removed).toBe(false) + + const suggest = await fetch(`${handle.url}/api/chat/files/suggest`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: 'package', + workspaceCwd: process.cwd(), + limit: 5, + }), + }) + expect(suggest.status).toBe(200) + const suggestBody = (await readJson(suggest)) as { + success: true + data: { items: unknown[] } + } + expect(suggestBody.success).toBe(true) + expect(Array.isArray(suggestBody.data.items)).toBe(true) + + const detail = await fetch(`${handle.url}/api/chat/sessions/${createdBody.data.id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + expect(detail.status).toBe(200) + + const restore = await fetch( + `${handle.url}/api/chat/sessions/${createdBody.data.id}/history`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ], + }), + }, + ) + expect(restore.status).toBe(200) + + const compact = await fetch( + `${handle.url}/api/chat/sessions/${createdBody.data.id}/compact`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }, + ) + expect(compact.status).toBe(200) + + const removeHistory = await fetch(`${handle.url}/api/sessions/${createdBody.data.id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + expect(removeHistory.status).toBe(200) + const removeHistoryBody = (await readJson(removeHistory)) as { + success: true + data: { deleted: boolean } + } + expect(removeHistoryBody.success).toBe(true) + expect(typeof removeHistoryBody.data.deleted).toBe('boolean') + } finally { + await handle.close() + } + }) + + test('allows browsing directories outside current working directory', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'memo-http-browser-')) + const handle = await startCoreHttpServer({ + host: '127.0.0.1', + port: 0, + password: 'test-password', + }) + + try { + const login = await fetch(`${handle.url}/api/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ password: 'test-password' }), + }) + expect(login.status).toBe(200) + const loginBody = (await readJson(login)) as { + success: true + data: { accessToken: string } + } + expect(loginBody.success).toBe(true) + + const listed = await fetch( + `${handle.url}/api/workspaces/fs/list?path=${encodeURIComponent(outsideDir)}`, + { + headers: { + Authorization: `Bearer ${loginBody.data.accessToken}`, + }, + }, + ) + expect(listed.status).toBe(200) + + const listedBody = (await readJson(listed)) as { + success: true + data: { path: string } + } + expect(listedBody.success).toBe(true) + expect(listedBody.data.path).toBe(normalizePath(await realpath(outsideDir))) + } finally { + await handle.close() + await rm(outsideDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/src/server/http_server.ts b/packages/core/src/server/http_server.ts new file mode 100644 index 0000000..938bdda --- /dev/null +++ b/packages/core/src/server/http_server.ts @@ -0,0 +1,169 @@ +import { randomUUID } from 'node:crypto' +import { createServer } from 'node:http' +import { CoreAuth } from '@memo/core/server/handler/auth' +import { createWorkspaceState } from '@memo/core/server/handler/workspace' +import { CoreSessionManager } from '@memo/core/server/handler/session_manager' +import { registerCoreApiRoutes } from '@memo/core/server/router/api_routes' +import { HttpRouter } from '@memo/core/server/router/http_router' +import { SseHub } from '@memo/core/server/utils/sse' +import { + applyCors, + normalizeError, + serveStatic, + writeError, + writeNoContent, + type CoreCorsOptions, +} from '@memo/core/server/utils/http' + +const DEFAULT_HOST = '127.0.0.1' +const DEFAULT_PORT = 5494 + +export type CoreHttpServerOptions = { + host?: string + port?: number + password?: string + memoHome?: string + cors?: CoreCorsOptions + staticDir?: string + tokenTtlSeconds?: number +} + +export type CoreHttpServerHandle = { + url: string + openApiSpecPath: string + close: () => Promise +} + +let activeServerHandle: CoreHttpServerHandle | null = null + +function normalizeHost(value: string | undefined): string { + const trimmed = value?.trim() + return trimmed || DEFAULT_HOST +} + +function normalizePort(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isInteger(value)) return DEFAULT_PORT + if (value === 0) return 0 + if (value < 1 || value > 65535) return DEFAULT_PORT + return value +} + +function resolvePassword(value: string | undefined): string { + const password = value?.trim() || process.env.MEMO_SERVER_PASSWORD?.trim() + if (!password) { + throw new Error('MEMO_SERVER_PASSWORD is required to start core HTTP server') + } + return password +} + +function formatServerUrl(host: string, port: number): string { + const safeHost = host.includes(':') ? `[${host}]` : host + return `http://${safeHost}:${port}` +} + +export async function startCoreHttpServer( + options: CoreHttpServerOptions = {}, +): Promise { + if (activeServerHandle) { + await activeServerHandle.close() + activeServerHandle = null + } + + const host = normalizeHost(options.host) + const port = normalizePort(options.port) + const password = resolvePassword(options.password) + const auth = new CoreAuth({ + password, + tokenTtlSeconds: options.tokenTtlSeconds, + }) + + const sseHub = new SseHub() + const sessionManager = new CoreSessionManager({ + sseHub, + memoHome: options.memoHome, + }) + const workspaceState = createWorkspaceState() + + const router = new HttpRouter() + let serverUrl = formatServerUrl(host, port) + + registerCoreApiRoutes({ + router, + auth, + sessionManager, + sseHub, + workspaceState, + getServerUrl: () => serverUrl, + }) + + const server = createServer(async (req, res) => { + const requestId = randomUUID() + const url = new URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`) + applyCors(req, res, options.cors) + + if ((req.method || 'GET').toUpperCase() === 'OPTIONS') { + writeNoContent(res) + return + } + + try { + const handled = await router.handle({ req, res, requestId, url }) + if (handled) return + + if (options.staticDir && !url.pathname.startsWith('/api/')) { + const served = await serveStatic(options.staticDir, url.pathname, res) + if (served) return + } + + writeError(res, requestId, url.pathname, 404, 'NOT_FOUND', 'Route not found') + } catch (error) { + const normalized = normalizeError(error) + writeError( + res, + requestId, + url.pathname, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen({ host, port }, () => { + server.off('error', rejectListen) + resolveListen() + }) + }) + + const address = server.address() + const resolvedPort = + address && typeof address === 'object' && 'port' in address ? address.port : port + serverUrl = formatServerUrl(host, resolvedPort) + + const handle: CoreHttpServerHandle = { + url: serverUrl, + openApiSpecPath: '/api/openapi.json', + close: async () => { + await sessionManager.close() + sseHub.close() + await new Promise((resolveClose) => { + server.close(() => resolveClose()) + }) + if (activeServerHandle === handle) { + activeServerHandle = null + } + }, + } + + activeServerHandle = handle + return handle +} + +export async function stopCoreHttpServer(): Promise { + if (!activeServerHandle) return + await activeServerHandle.close() + activeServerHandle = null +} diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts new file mode 100644 index 0000000..8d4dc12 --- /dev/null +++ b/packages/core/src/server/index.ts @@ -0,0 +1,9 @@ +export * from './http_server' +export * from './handler/auth' +export * from './handler/session_manager' +export * from './handler/workspace' +export * from './router/api_routes' +export * from './router/http_router' +export * from './router/openapi' +export * from './utils/http' +export * from './utils/sse' diff --git a/packages/core/src/server/router/api_routes.ts b/packages/core/src/server/router/api_routes.ts new file mode 100644 index 0000000..88b99e2 --- /dev/null +++ b/packages/core/src/server/router/api_routes.ts @@ -0,0 +1,632 @@ +import { CoreAuth } from '@memo/core/server/handler/auth' +import { CoreSessionManager } from '@memo/core/server/handler/session_manager' +import { + createMcpServer, + getMcpServer, + listMcpServers, + loginMcpServer, + logoutMcpServer, + removeMcpServer, + setActiveMcpServers, + updateMcpServer, +} from '@memo/core/runtime/mcp_admin' +import { getFileSuggestions } from '@memo/core/runtime/file_suggestions' +import { + createSkill, + getSkill, + listSkills, + removeSkill, + setActiveSkills, + updateSkill, +} from '@memo/core/runtime/skills_admin' +import { buildOpenApiSpec } from '@memo/core/server/router/openapi' +import { + HttpRouter, + type RouteContext, + type RouteMethod, +} from '@memo/core/server/router/http_router' +import { SseHub } from '@memo/core/server/utils/sse' +import type { AuthLoginRequest } from '@memo/core/web/types' +import { + ensureAuth, + HttpApiError, + normalizeError, + parseInteger, + readJsonBody, + requireString, + writeError, + writeSuccess, +} from '@memo/core/server/utils/http' +import { + buildWorkspaceRecord, + listWorkspaces, + listWorkspaceDirectories, + type WorkspaceState, +} from '@memo/core/server/handler/workspace' + +export type RegisterCoreApiRoutesOptions = { + router: HttpRouter + auth: CoreAuth + sessionManager: CoreSessionManager + sseHub: SseHub + workspaceState: WorkspaceState + getServerUrl: () => string +} + +export function registerCoreApiRoutes(options: RegisterCoreApiRoutesOptions): void { + const { auth, getServerUrl, router, sessionManager, sseHub, workspaceState } = options + + const registerJsonRoute = ( + method: RouteMethod, + path: string, + handler: (context: RouteContext, body: Record) => Promise, + authRequired = true, + ) => { + router.register(method, path, async (context) => { + try { + if (authRequired) { + ensureAuth(auth, context.req) + } + const body = + method === 'GET' || method === 'DELETE' ? {} : await readJsonBody(context.req) + const result = await handler(context, body) + if (!context.res.writableEnded) { + writeSuccess(context.res, context.requestId, result) + } + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + } + + registerJsonRoute( + 'POST', + '/api/auth/login', + async (_context, body) => { + const passwordInput = requireString(body as AuthLoginRequest, 'password') + return auth.login(passwordInput) + }, + false, + ) + + router.register('GET', '/api/openapi.json', async (context) => { + try { + writeSuccess( + context.res, + context.requestId, + buildOpenApiSpec({ serverUrl: getServerUrl() }), + ) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('POST', '/api/chat/sessions', async (_context, body) => { + return sessionManager.createSession({ + sessionId: typeof body.sessionId === 'string' ? body.sessionId : undefined, + providerName: typeof body.providerName === 'string' ? body.providerName : undefined, + cwd: typeof body.cwd === 'string' ? body.cwd : undefined, + toolPermissionMode: + body.toolPermissionMode === 'none' || + body.toolPermissionMode === 'once' || + body.toolPermissionMode === 'full' + ? body.toolPermissionMode + : undefined, + activeMcpServers: Array.isArray(body.activeMcpServers) + ? body.activeMcpServers.filter((item): item is string => typeof item === 'string') + : undefined, + }) + }) + + router.register('GET', '/api/chat/sessions/providers', async (context) => { + try { + ensureAuth(auth, context.req) + const result = await sessionManager.listProviders() + writeSuccess(context.res, context.requestId, result) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/chat/runtimes', async (context) => { + try { + ensureAuth(auth, context.req) + const workspaceCwd = context.query.get('workspaceCwd') ?? undefined + const result = sessionManager.listRuntimeBadges({ workspaceCwd }) + writeSuccess(context.res, context.requestId, result) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/chat/sessions/:id', async (context) => { + try { + ensureAuth(auth, context.req) + const session = sessionManager.getSessionState(context.params.id!) + if (!session) { + throw new HttpApiError(404, 'SESSION_NOT_FOUND', 'session not found') + } + writeSuccess(context.res, context.requestId, session) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('DELETE', '/api/chat/sessions/:id', async (context) => { + return sessionManager.closeSession(context.params.id!) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/messages', async (context, body) => { + return sessionManager.submitMessage(context.params.id!, requireString(body, 'input')) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/input', async (context, body) => { + return sessionManager.submitMessage(context.params.id!, requireString(body, 'input')) + }) + + registerJsonRoute('DELETE', '/api/chat/sessions/:id/queue/:queueId', async (context) => { + return sessionManager.removeQueuedInput(context.params.id!, context.params.queueId!) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/queue/send_now', async (context) => { + return sessionManager.sendQueuedInputNow(context.params.id!) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/history', async (context, body) => { + const messages = body.messages + if (!Array.isArray(messages)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'messages must be an array') + } + return sessionManager.restoreHistory(context.params.id!, messages) + }) + + registerJsonRoute('POST', '/api/chat/files/suggest', async (_context, body) => { + const query = requireString(body, 'query') + const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '' + const workspaceCwd = typeof body.workspaceCwd === 'string' ? body.workspaceCwd.trim() : '' + const sessionCwd = sessionId ? sessionManager.resolveSessionCwd(sessionId) : null + const cwd = sessionCwd || workspaceCwd + + if (!cwd) { + throw new HttpApiError( + 400, + 'BAD_REQUEST', + 'workspaceCwd is required when sessionId is unavailable', + ) + } + + const limit = + typeof body.limit === 'number' && Number.isFinite(body.limit) + ? Math.max(1, Math.floor(body.limit)) + : undefined + + const items = await getFileSuggestions({ + cwd, + query, + limit, + }) + return { items } + }) + + router.register('GET', '/api/chat/sessions/:id/events', async (context) => { + try { + ensureAuth(auth, context.req) + const session = sessionManager.getSessionState(context.params.id!) + if (!session) { + throw new HttpApiError(404, 'SESSION_NOT_FOUND', 'session not found') + } + + sseHub.subscribe(context.params.id!, context.req, context.res) + sseHub.publish(context.params.id!, 'session.snapshot', session) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/cancel', async (context) => { + return sessionManager.cancelTurn(context.params.id!) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/compact', async (context) => { + return sessionManager.compactSession(context.params.id!) + }) + + registerJsonRoute('POST', '/api/chat/sessions/:id/approval', async (context, body) => { + const fingerprint = requireString(body, 'fingerprint') + const decision = requireString(body, 'decision') + if (decision !== 'once' && decision !== 'session' && decision !== 'deny') { + throw new HttpApiError(400, 'BAD_REQUEST', 'decision must be once | session | deny') + } + return sessionManager.applyApprovalDecision(context.params.id!, fingerprint, decision) + }) + + registerJsonRoute( + 'POST', + '/api/chat/sessions/:id/approvals/:fingerprint', + async (context, body) => { + const decision = requireString(body, 'decision') + if (decision !== 'once' && decision !== 'session' && decision !== 'deny') { + throw new HttpApiError(400, 'BAD_REQUEST', 'decision must be once | session | deny') + } + return sessionManager.applyApprovalDecision( + context.params.id!, + context.params.fingerprint!, + decision, + ) + }, + ) + + router.register('GET', '/api/sessions', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await sessionManager.listSessions({ + page: parseInteger(context.query.get('page'), 1), + pageSize: parseInteger(context.query.get('pageSize'), 20), + sortBy: + context.query.get('sortBy') === 'updatedAt' || + context.query.get('sortBy') === 'startedAt' || + context.query.get('sortBy') === 'project' || + context.query.get('sortBy') === 'title' + ? (context.query.get('sortBy') as + | 'updatedAt' + | 'startedAt' + | 'project' + | 'title') + : undefined, + order: + context.query.get('order') === 'asc' || context.query.get('order') === 'desc' + ? (context.query.get('order') as 'asc' | 'desc') + : undefined, + project: context.query.get('project') ?? undefined, + workspaceCwd: context.query.get('workspaceCwd') ?? undefined, + dateFrom: context.query.get('dateFrom') ?? undefined, + dateTo: context.query.get('dateTo') ?? undefined, + q: context.query.get('q') ?? undefined, + }) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/sessions/:id', async (context) => { + try { + ensureAuth(auth, context.req) + const detail = await sessionManager.getSessionDetail(context.params.id!) + if (!detail) { + throw new HttpApiError(404, 'SESSION_NOT_FOUND', 'session not found') + } + writeSuccess(context.res, context.requestId, detail) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/sessions/:id/events', async (context) => { + try { + ensureAuth(auth, context.req) + const detail = await sessionManager.getSessionEvents( + context.params.id!, + context.query.get('cursor') ?? undefined, + parseInteger(context.query.get('limit'), 100), + ) + if (!detail) { + throw new HttpApiError(404, 'SESSION_NOT_FOUND', 'session not found') + } + writeSuccess(context.res, context.requestId, detail) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('DELETE', '/api/sessions/:id', async (context) => { + return sessionManager.removeSessionHistory(context.params.id!) + }) + + router.register('GET', '/api/mcp/servers', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await listMcpServers() + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/mcp/servers/:name', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await getMcpServer(context.params.name!) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('POST', '/api/mcp/servers', async (_context, body) => { + const name = requireString(body, 'name') + return createMcpServer(name, body.config) + }) + + registerJsonRoute('PUT', '/api/mcp/servers/:name', async (context, body) => { + return updateMcpServer(context.params.name!, body.config) + }) + + registerJsonRoute('DELETE', '/api/mcp/servers/:name', async (context) => { + return removeMcpServer(context.params.name!) + }) + + registerJsonRoute('POST', '/api/mcp/servers/:name/login', async (context, body) => { + const scopes = Array.isArray(body.scopes) + ? body.scopes.filter((item): item is string => typeof item === 'string') + : undefined + return loginMcpServer(context.params.name!, scopes) + }) + + registerJsonRoute('POST', '/api/mcp/servers/:name/logout', async (context) => { + return logoutMcpServer(context.params.name!) + }) + + registerJsonRoute('POST', '/api/mcp/active', async (_context, body) => { + const names = Array.isArray(body.names) + ? body.names.filter((item): item is string => typeof item === 'string') + : [] + return setActiveMcpServers(names) + }) + + router.register('GET', '/api/skills', async (context) => { + try { + ensureAuth(auth, context.req) + const workspaceCwd = context.query.get('workspaceCwd') + const scope = context.query.get('scope') + const q = context.query.get('q') + const data = await listSkills({ + workspaceCwd, + scope, + q, + }) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + router.register('GET', '/api/skills/:id', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await getSkill(context.params.id!) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('POST', '/api/skills', async (_context, body) => { + return createSkill({ + scope: body.scope, + workspaceCwd: typeof body.workspaceCwd === 'string' ? body.workspaceCwd : undefined, + name: body.name, + description: body.description, + content: body.content, + }) + }) + + registerJsonRoute('PATCH', '/api/skills/:id', async (context, body) => { + return updateSkill(context.params.id!, { + description: body.description, + content: body.content, + }) + }) + + registerJsonRoute('DELETE', '/api/skills/:id', async (context) => { + return removeSkill(context.params.id!) + }) + + registerJsonRoute('POST', '/api/skills/active', async (_context, body) => { + const ids = Array.isArray(body.ids) + ? body.ids.filter((item): item is string => typeof item === 'string') + : [] + return setActiveSkills(ids) + }) + + router.register('GET', '/api/workspaces', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await listWorkspaces(sessionManager, workspaceState) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('POST', '/api/workspaces', async (_context, body) => { + const cwd = requireString(body, 'cwd') + const name = typeof body.name === 'string' ? body.name : undefined + const next = buildWorkspaceRecord(cwd, name) + workspaceState.overrides.set(next.id, next) + workspaceState.removedIds.delete(next.id) + return { + created: true, + item: next, + } + }) + + registerJsonRoute('PATCH', '/api/workspaces/:id', async (context, body) => { + const name = requireString(body, 'name') + const current = workspaceState.overrides.get(context.params.id!) + if (!current) { + const all = await listWorkspaces(sessionManager, workspaceState) + const found = all.items.find((item) => item.id === context.params.id!) + if (!found) { + throw new HttpApiError(404, 'WORKSPACE_NOT_FOUND', 'workspace not found') + } + workspaceState.overrides.set(context.params.id!, { + ...found, + name, + lastUsedAt: new Date().toISOString(), + }) + } else { + workspaceState.overrides.set(context.params.id!, { + ...current, + name, + lastUsedAt: new Date().toISOString(), + }) + } + + return { + updated: true, + item: workspaceState.overrides.get(context.params.id!), + } + }) + + registerJsonRoute('DELETE', '/api/workspaces/:id', async (context) => { + workspaceState.overrides.delete(context.params.id!) + workspaceState.removedIds.add(context.params.id!) + return { deleted: true } + }) + + router.register('GET', '/api/workspaces/fs/list', async (context) => { + try { + ensureAuth(auth, context.req) + const data = await listWorkspaceDirectories(context.query.get('path')) + writeSuccess(context.res, context.requestId, data) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) +} diff --git a/packages/core/src/server/router/http_router.ts b/packages/core/src/server/router/http_router.ts new file mode 100644 index 0000000..db3d59c --- /dev/null +++ b/packages/core/src/server/router/http_router.ts @@ -0,0 +1,98 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type RouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' + +export type RouteContext = { + req: IncomingMessage + res: ServerResponse + requestId: string + path: string + query: URLSearchParams + params: Record +} + +export type RouteHandler = (context: RouteContext) => Promise | void + +type RouteEntry = { + method: RouteMethod + pattern: string + segments: string[] + handler: RouteHandler +} + +function normalizePath(pathname: string): string { + if (!pathname || pathname === '/') return '/' + return pathname.replace(/\/+$/g, '') || '/' +} + +function toSegments(pathname: string): string[] { + const normalized = normalizePath(pathname) + if (normalized === '/') return [] + return normalized + .split('/') + .map((part) => part.trim()) + .filter(Boolean) +} + +function matchPath(pattern: string[], actual: string[]): Record | null { + if (pattern.length !== actual.length) return null + + const params: Record = {} + for (let index = 0; index < pattern.length; index += 1) { + const expected = pattern[index] + const value = actual[index] + if (!expected || !value) return null + + if (expected.startsWith(':')) { + params[expected.slice(1)] = decodeURIComponent(value) + continue + } + if (expected !== value) { + return null + } + } + + return params +} + +export class HttpRouter { + private readonly routes: RouteEntry[] = [] + + register(method: RouteMethod, pattern: string, handler: RouteHandler): void { + this.routes.push({ + method, + pattern: normalizePath(pattern), + segments: toSegments(pattern), + handler, + }) + } + + async handle(options: { + req: IncomingMessage + res: ServerResponse + requestId: string + url: URL + }): Promise { + const method = (options.req.method || 'GET').toUpperCase() as RouteMethod + const path = normalizePath(options.url.pathname) + const pathSegments = toSegments(path) + + for (const route of this.routes) { + if (route.method !== method) continue + const params = matchPath(route.segments, pathSegments) + if (!params) continue + + await route.handler({ + req: options.req, + res: options.res, + requestId: options.requestId, + path, + query: options.url.searchParams, + params, + }) + return true + } + + return false + } +} diff --git a/packages/core/src/server/router/openapi.ts b/packages/core/src/server/router/openapi.ts new file mode 100644 index 0000000..c4cd9c9 --- /dev/null +++ b/packages/core/src/server/router/openapi.ts @@ -0,0 +1,131 @@ +export function buildOpenApiSpec(options: { serverUrl: string }): Record { + return { + openapi: '3.1.0', + info: { + title: 'Memo Core HTTP API', + version: '0.1.0', + description: 'Core server endpoints for chat runtime, sessions, and admin APIs.', + }, + servers: [{ url: options.serverUrl }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + security: [{ bearerAuth: [] }], + paths: { + '/api/openapi.json': { + get: { + summary: 'Get OpenAPI document', + security: [], + }, + }, + '/api/auth/login': { + post: { + summary: 'Login with shared password', + security: [], + }, + }, + '/api/chat/sessions': { + post: { + summary: 'Create live session', + }, + }, + '/api/chat/sessions/{id}': { + get: { + summary: 'Get session live state', + }, + delete: { + summary: 'Close session', + }, + }, + '/api/chat/sessions/{id}/messages': { + post: { + summary: 'Submit input to live session queue', + }, + }, + '/api/chat/sessions/providers': { + get: { + summary: 'List available chat providers', + }, + }, + '/api/chat/runtimes': { + get: { + summary: 'List live runtime badges', + }, + }, + '/api/chat/sessions/{id}/queue/{queueId}': { + delete: { + summary: 'Remove queued input', + }, + }, + '/api/chat/sessions/{id}/queue/send_now': { + post: { + summary: 'Trigger queued input processing', + }, + }, + '/api/chat/sessions/{id}/history': { + post: { + summary: 'Restore session history messages', + }, + }, + '/api/chat/files/suggest': { + post: { + summary: 'Suggest files for chat input', + }, + }, + '/api/chat/sessions/{id}/events': { + get: { + summary: 'Subscribe SSE events', + }, + }, + '/api/chat/sessions/{id}/cancel': { + post: { + summary: 'Cancel current turn', + }, + }, + '/api/chat/sessions/{id}/compact': { + post: { + summary: 'Manual compact for session history', + }, + }, + '/api/chat/sessions/{id}/approval': { + post: { + summary: 'Respond pending approval request', + }, + }, + '/api/sessions': { + get: { + summary: 'List persisted session history', + }, + }, + '/api/sessions/{id}': { + get: { + summary: 'Get persisted session detail', + }, + delete: { + summary: 'Delete persisted session history', + }, + }, + '/api/sessions/{id}/events': { + get: { + summary: 'Get persisted session events', + }, + }, + '/api/mcp/servers': { + get: { + summary: 'List MCP servers', + }, + }, + '/api/skills': { + get: { + summary: 'List skills', + }, + }, + }, + } +} diff --git a/packages/core/src/server/utils/http.ts b/packages/core/src/server/utils/http.ts new file mode 100644 index 0000000..5c4afa9 --- /dev/null +++ b/packages/core/src/server/utils/http.ts @@ -0,0 +1,290 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { extname, join, resolve } from 'node:path' +import { readFile, stat } from 'node:fs/promises' +import { CoreAuth, CoreAuthError } from '@memo/core/server/handler/auth' +import { McpAdminError } from '@memo/core/runtime/mcp_admin' +import { SkillsAdminError } from '@memo/core/runtime/skills_admin' +import type { ApiEnvelope, OpenApiError } from '@memo/core/web/types' + +const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024 + +export type CoreCorsOptions = { + origin?: string | string[] | '*' +} + +export class HttpApiError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + readonly details?: unknown, + ) { + super(message) + } +} + +function parseAuthorizationToken(req: IncomingMessage): string | null { + const header = req.headers.authorization + if (!header || typeof header !== 'string') return null + if (!header.startsWith('Bearer ')) return null + return header.slice('Bearer '.length).trim() +} + +function toEnvelope(requestId: string, payload: T): ApiEnvelope { + return { + success: true, + data: payload, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + } +} + +function toErrorEnvelope(requestId: string, path: string, error: OpenApiError): ApiEnvelope { + return { + success: false, + error: { + code: error.code, + message: error.message, + details: error.details, + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + path, + }, + } +} + +function writeJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.statusCode = statusCode + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify(body)) +} + +export function writeSuccess(res: ServerResponse, requestId: string, data: T): void { + writeJson(res, 200, toEnvelope(requestId, data)) +} + +export function writeNoContent(res: ServerResponse): void { + res.statusCode = 204 + res.end() +} + +export function writeError( + res: ServerResponse, + requestId: string, + path: string, + statusCode: number, + code: string, + message: string, + details?: unknown, +): void { + writeJson( + res, + statusCode, + toErrorEnvelope(requestId, path, { + code, + message, + details, + }), + ) +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + + for await (const chunk of req) { + const asBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += asBuffer.byteLength + if (totalBytes > MAX_JSON_BODY_BYTES) { + throw new HttpApiError(413, 'PAYLOAD_TOO_LARGE', 'Request body is too large') + } + chunks.push(asBuffer) + } + + return Buffer.concat(chunks).toString('utf8') +} + +export async function readJsonBody(req: IncomingMessage): Promise> { + const text = await readBody(req) + if (!text.trim()) return {} + + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new HttpApiError(400, 'BAD_JSON', 'Request body must be valid JSON') + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new HttpApiError(400, 'BAD_JSON', 'JSON body must be an object') + } + + return parsed as Record +} + +export function requireString( + input: Record, + field: string, + errorMessage = `${field} is required`, +): string { + const value = input[field] + if (typeof value !== 'string') { + throw new HttpApiError(400, 'BAD_REQUEST', errorMessage) + } + const trimmed = value.trim() + if (!trimmed) { + throw new HttpApiError(400, 'BAD_REQUEST', errorMessage) + } + return trimmed +} + +export function parseInteger(value: string | null, fallback: number): number { + if (!value) return fallback + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed)) return fallback + return parsed +} + +export function normalizeError(error: unknown): HttpApiError { + if (error instanceof HttpApiError) return error + + if (error instanceof CoreAuthError) { + if (error.code === 'INVALID_CREDENTIALS') { + return new HttpApiError(401, 'INVALID_CREDENTIALS', error.message) + } + if (error.code === 'TOKEN_EXPIRED') { + return new HttpApiError(401, 'TOKEN_EXPIRED', error.message) + } + return new HttpApiError(401, 'TOKEN_INVALID', error.message) + } + + if (error instanceof McpAdminError) { + const statusCode = error.code === 'NOT_FOUND' ? 404 : 400 + return new HttpApiError(statusCode, error.code, error.message) + } + + if (error instanceof SkillsAdminError) { + const statusCode = error.code === 'NOT_FOUND' ? 404 : 400 + return new HttpApiError(statusCode, error.code, error.message) + } + + const message = (error as Error)?.message || 'Internal server error' + if (message.startsWith('session not found')) { + return new HttpApiError(404, 'SESSION_NOT_FOUND', message) + } + if (message.startsWith('approval not found')) { + return new HttpApiError(404, 'APPROVAL_NOT_FOUND', message) + } + if (message.includes('queue is full')) { + return new HttpApiError(409, 'QUEUE_FULL', message) + } + if (message.includes('Too many live sessions')) { + return new HttpApiError(429, 'TOO_MANY_SESSIONS', message) + } + + return new HttpApiError(500, 'INTERNAL_ERROR', message) +} + +export function ensureAuth(auth: CoreAuth, req: IncomingMessage): void { + const token = parseAuthorizationToken(req) + if (!token) { + throw new HttpApiError(401, 'UNAUTHORIZED', 'Missing Bearer token') + } + auth.verify(token) +} + +export function applyCors( + req: IncomingMessage, + res: ServerResponse, + options: CoreCorsOptions | undefined, +): void { + const originHeader = req.headers.origin + const allowedOrigin = options?.origin ?? '*' + + if (allowedOrigin === '*') { + res.setHeader('Access-Control-Allow-Origin', '*') + } else if (Array.isArray(allowedOrigin)) { + const matched = + typeof originHeader === 'string' + ? allowedOrigin.find((item) => item === originHeader) + : undefined + if (matched) { + res.setHeader('Access-Control-Allow-Origin', matched) + } + } else if (typeof allowedOrigin === 'string') { + res.setHeader('Access-Control-Allow-Origin', allowedOrigin) + } + + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type') +} + +function getContentType(filePath: string): string { + const ext = extname(filePath).toLowerCase() + if (ext === '.html') return 'text/html; charset=utf-8' + if (ext === '.js') return 'application/javascript; charset=utf-8' + if (ext === '.css') return 'text/css; charset=utf-8' + if (ext === '.json') return 'application/json; charset=utf-8' + if (ext === '.svg') return 'image/svg+xml' + if (ext === '.png') return 'image/png' + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.woff') return 'font/woff' + if (ext === '.woff2') return 'font/woff2' + return 'application/octet-stream' +} + +export async function serveStatic( + staticDir: string, + reqPath: string, + res: ServerResponse, +): Promise { + const root = resolve(staticDir) + const decodedPath = decodeURIComponent(reqPath) + const rawTarget = decodedPath === '/' ? '/index.html' : decodedPath + + const resolveCandidate = (relativePath: string) => { + const resolved = resolve(join(root, relativePath)) + if (resolved !== root && !resolved.startsWith(`${root}/`)) { + throw new HttpApiError(403, 'FORBIDDEN', 'Invalid static path') + } + return resolved + } + + const hasExtension = extname(rawTarget).length > 0 + const candidates = hasExtension ? [rawTarget] : [rawTarget, '/index.html'] + + for (const candidate of candidates) { + const relative = candidate.startsWith('/') ? candidate.slice(1) : candidate + const absolute = resolveCandidate(relative) + try { + const fileStat = await stat(absolute) + if (!fileStat.isFile()) continue + const content = await readFile(absolute) + res.statusCode = 200 + res.setHeader('Content-Type', getContentType(absolute)) + res.end(content) + return true + } catch { + // Try next candidate. + } + } + + if (!hasExtension) { + const fallback = resolveCandidate('index.html') + try { + const content = await readFile(fallback) + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(content) + return true + } catch { + // fall through + } + } + + return false +} diff --git a/packages/core/src/server/utils/sse.ts b/packages/core/src/server/utils/sse.ts new file mode 100644 index 0000000..79527dc --- /dev/null +++ b/packages/core/src/server/utils/sse.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { SseEventEnvelope } from '@memo/core/web/types' + +type SseClient = { + id: string + res: ServerResponse +} + +export class SseHub { + private readonly clientsBySession = new Map>() + private readonly seqBySession = new Map() + private readonly heartbeatTimer: NodeJS.Timeout + + constructor(heartbeatIntervalMs = 20_000) { + this.heartbeatTimer = setInterval(() => { + this.heartbeat() + }, heartbeatIntervalMs) + this.heartbeatTimer.unref() + } + + subscribe(sessionId: string, req: IncomingMessage, res: ServerResponse): void { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8') + res.setHeader('Cache-Control', 'no-cache, no-transform') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.setHeader('Access-Control-Allow-Origin', '*') + res.flushHeaders?.() + + res.write(': connected\n\n') + + const clientId = randomUUID() + const sessionClients = this.clientsBySession.get(sessionId) ?? new Map() + sessionClients.set(clientId, { id: clientId, res }) + this.clientsBySession.set(sessionId, sessionClients) + + const cleanup = () => { + this.unsubscribe(sessionId, clientId) + } + + req.once('close', cleanup) + req.once('aborted', cleanup) + res.once('close', cleanup) + res.once('error', cleanup) + } + + publish(sessionId: string, event: string, data: unknown): void { + const clients = this.clientsBySession.get(sessionId) + if (!clients || clients.size === 0) return + + const envelope = this.buildEnvelope(sessionId, event, data) + const payload = JSON.stringify(envelope) + const frame = `id: ${envelope.seq}\nevent: ${event}\ndata: ${payload}\n\n` + + for (const [clientId, client] of clients.entries()) { + try { + client.res.write(frame) + } catch { + this.unsubscribe(sessionId, clientId) + } + } + } + + closeSession(sessionId: string): void { + const clients = this.clientsBySession.get(sessionId) + if (!clients) return + + for (const client of clients.values()) { + try { + client.res.end() + } catch { + // Ignore socket errors during shutdown. + } + } + + this.clientsBySession.delete(sessionId) + this.seqBySession.delete(sessionId) + } + + close(): void { + clearInterval(this.heartbeatTimer) + for (const sessionId of this.clientsBySession.keys()) { + this.closeSession(sessionId) + } + this.clientsBySession.clear() + this.seqBySession.clear() + } + + private unsubscribe(sessionId: string, clientId: string): void { + const clients = this.clientsBySession.get(sessionId) + if (!clients) return + clients.delete(clientId) + if (clients.size === 0) { + this.clientsBySession.delete(sessionId) + } + } + + private heartbeat(): void { + for (const [sessionId, clients] of this.clientsBySession.entries()) { + for (const [clientId, client] of clients.entries()) { + try { + client.res.write(': keep-alive\n\n') + } catch { + this.unsubscribe(sessionId, clientId) + } + } + } + } + + private buildEnvelope(sessionId: string, event: string, data: unknown): SseEventEnvelope { + const nextSeq = (this.seqBySession.get(sessionId) ?? 0) + 1 + this.seqBySession.set(sessionId, nextSeq) + + return { + event, + data, + seq: nextSeq, + ts: new Date().toISOString(), + } + } +} diff --git a/packages/core/src/utils/tokenizer.ts b/packages/core/src/utils/tokenizer.ts index 2e94170..37fe3a6 100644 --- a/packages/core/src/utils/tokenizer.ts +++ b/packages/core/src/utils/tokenizer.ts @@ -1,25 +1,101 @@ -/** @file tiktoken wrapper: for prompt/response token counting and encoding management. */ -import { encoding_for_model, get_encoding, type Tiktoken } from '@dqbd/tiktoken' +/** @file Lightweight token estimator used for context monitoring and fallback accounting. */ import type { ChatMessage, TokenCounter } from '@memo/core/types' const DEFAULT_TOKENIZER_MODEL = 'cl100k_base' -type EncodingFactory = () => Tiktoken - -function safeEncodingFactory(model?: string): { model: string; factory: EncodingFactory } { - const resolvedModel = model?.trim() || DEFAULT_TOKENIZER_MODEL - try { - // encoding_for_model requires strict model names; using type assertion for dynamic input compatibility. - const factory = () => encoding_for_model(resolvedModel as any) - factory().free() - return { model: resolvedModel, factory } - } catch { - // Fallback to generic cl100k_base for unknown models to avoid throwing. - const fallbackModel = DEFAULT_TOKENIZER_MODEL - const factory = () => get_encoding(fallbackModel) - factory().free() - return { model: fallbackModel, factory } +const KNOWN_MODEL_ALIASES = new Set([ + 'cl100k_base', + 'gpt-4', + 'gpt-4o', + 'gpt-4.1', + 'gpt-5', + 'o1', + 'o3', + 'o4', + 'deepseek-chat', + 'deepseek-reasoner', + 'claude-3-5-sonnet', + 'gemini-2.5-pro', +]) + +const KNOWN_MODEL_PATTERNS = [ + /^gpt[-_.a-z0-9]+$/i, + /^o[1-9][-_.a-z0-9]*$/i, + /^claude[-_.a-z0-9]+$/i, + /^gemini[-_.a-z0-9]+$/i, + /^deepseek[-_.a-z0-9]+$/i, + /^qwen[-_.a-z0-9]+$/i, + /^llama[-_.a-z0-9]+$/i, + /^mistral[-_.a-z0-9]+$/i, + /^[a-z0-9]+\/[a-z0-9][-_.a-z0-9]*$/i, +] + +function normalizeModelName(model?: string): string { + return model?.trim() || DEFAULT_TOKENIZER_MODEL +} + +function isKnownModelAlias(model: string): boolean { + if (KNOWN_MODEL_ALIASES.has(model.toLowerCase())) return true + return KNOWN_MODEL_PATTERNS.some((pattern) => pattern.test(model)) +} + +function toResolvedModel(model?: string): string { + const normalized = normalizeModelName(model) + return isKnownModelAlias(normalized) ? normalized : DEFAULT_TOKENIZER_MODEL +} + +function isCjkCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x3040 && codePoint <= 0x30ff) || // hiragana / katakana + (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // cjk ext a + (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // cjk unified ideographs + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) // hangul syllables +} + +function estimateTextTokens(text: string): number { + if (!text) return 0 + + let asciiUnits = 0 + let cjkUnits = 0 + let otherUnits = 0 + let punctuationUnits = 0 + let newlineUnits = 0 + + for (const char of text) { + const codePoint = char.codePointAt(0) + if (!codePoint) continue + + if (char === '\n') newlineUnits += 1 + if (/\s/u.test(char)) { + asciiUnits += 0.15 + continue + } + if (isCjkCodePoint(codePoint)) { + cjkUnits += 1 + continue + } + + if (codePoint <= 0x7f) { + asciiUnits += 1 + if ( + (codePoint >= 33 && codePoint <= 47) || + (codePoint >= 58 && codePoint <= 64) || + (codePoint >= 91 && codePoint <= 96) || + (codePoint >= 123 && codePoint <= 126) + ) { + punctuationUnits += 1 + } + continue + } + + otherUnits += 1 } + + const estimated = + asciiUnits / 4 + cjkUnits + otherUnits * 0.75 + punctuationUnits * 0.12 + newlineUnits * 0.4 + + return Math.max(1, Math.ceil(estimated)) } function messagePayloadForCounting(message: ChatMessage): string { @@ -38,8 +114,7 @@ function messagePayloadForCounting(message: ChatMessage): string { /** Create a reusable tokenizer counter for prompt estimation and usage reconciliation. */ export function createTokenCounter(model?: string): TokenCounter { - const { model: resolvedModel, factory } = safeEncodingFactory(model) - const encoding = factory() + const resolvedModel = toResolvedModel(model) // ChatML rough estimation: each message includes role/name wrapping overhead // Reference OpenAI's common estimates for gpt-3.5/4: about 4 tokens per message, plus 2 tokens for assistant priming. @@ -48,8 +123,7 @@ export function createTokenCounter(model?: string): TokenCounter { const TOKENS_PER_NAME = 1 const countText = (text: string) => { - if (!text) return 0 - return encoding.encode(text).length + return estimateTextTokens(text) } const countMessages = (messages: ChatMessage[]) => { @@ -71,6 +145,6 @@ export function createTokenCounter(model?: string): TokenCounter { model: resolvedModel, countText, countMessages, - dispose: () => encoding.free(), + dispose: () => {}, } } diff --git a/packages/core/src/web/types.ts b/packages/core/src/web/types.ts index cb5164a..36c757c 100644 --- a/packages/core/src/web/types.ts +++ b/packages/core/src/web/types.ts @@ -13,6 +13,8 @@ export type ApiErrorMeta = ApiSuccessMeta & { path?: string } +export type OpenApiError = ApiErrorInfo + export type ApiEnvelope = | { success: true @@ -25,6 +27,22 @@ export type ApiEnvelope = meta: ApiErrorMeta } +export type AuthLoginRequest = { + password: string +} + +export type AuthLoginResponse = { + accessToken: string + expiresIn: number +} + +export type SseEventEnvelope = { + event: string + data: unknown + seq: number + ts: string +} + export type TokenUsageSummary = { prompt: number completion: number @@ -147,6 +165,8 @@ export type LiveSessionState = { queuedInputs: QueuedInputItem[] currentContextTokens?: number contextWindow?: number + historyFilePath?: string + availableToolNames?: string[] } export type WsServerEvent = @@ -171,6 +191,21 @@ export type WsServerEvent = usagePercent: number } } + | { + type: 'context.compact' + payload: { + turn: number + step: number + reason: 'auto' | 'manual' + status: 'success' | 'failed' | 'skipped' + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string + } + } | { type: 'tool.action' payload: { diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index c612d78..339e8ea 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -11,13 +11,5 @@ export default defineConfig({ minify: false, splitting: false, bundle: true, - external: [ - '@dqbd/tiktoken', - '@mozilla/readability', - 'ipaddr.js', - 'jsdom', - 'robots-parser', - 'turndown', - 'undici', - ], + external: ['@mozilla/readability', 'ipaddr.js', 'jsdom', 'robots-parser', 'turndown', 'undici'], }) diff --git a/packages/tui/src/App.tsx b/packages/tui/src/App.tsx index 4e22144..f98b989 100644 --- a/packages/tui/src/App.tsx +++ b/packages/tui/src/App.tsx @@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises' import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { Box, Text, useApp } from 'ink' import { - createAgentSession, loadMemoConfig, resolveContextWindowForProvider, selectProvider, @@ -25,6 +24,7 @@ import { McpActivationOverlay } from './overlays/McpActivationOverlay' import { notifyApprovalRequested } from './notifications/approval_notification' import { SetupWizard } from './setup/SetupWizard' import { parseHistoryLog } from './controllers/history_parser' +import { createHttpAgentSession } from './http/http_agent_session' import { chatTimelineReducer, createInitialTimelineState, @@ -346,7 +346,7 @@ export function App({ await previous.close() } - const created = await createAgentSession(deps, sessionOptionsState) + const created = await createHttpAgentSession(deps, sessionOptionsState) if (cancelled) { await created.close() return @@ -829,11 +829,41 @@ export function App({ useEffect(() => { if (!session || !pendingHistoryMessages?.length) return - const systemMessage = session.history[0] - if (!systemMessage) return - session.history.splice(0, session.history.length, systemMessage, ...pendingHistoryMessages) - setPendingHistoryMessages(null) - }, [pendingHistoryMessages, session]) + + let cancelled = false + ;(async () => { + try { + if ('restoreHistory' in session && typeof session.restoreHistory === 'function') { + await session.restoreHistory(pendingHistoryMessages) + } else { + const systemMessage = session.history[0] + if (!systemMessage) return + session.history.splice( + 0, + session.history.length, + systemMessage, + ...pendingHistoryMessages, + ) + } + if (!cancelled) { + setPendingHistoryMessages(null) + } + } catch (err) { + if (!cancelled) { + appendSystemMessage( + 'History', + `Failed to restore history: ${(err as Error).message}`, + 'error', + ) + setPendingHistoryMessages(null) + } + } + })() + + return () => { + cancelled = true + } + }, [appendSystemMessage, pendingHistoryMessages, session]) const handleApprovalDecision = useCallback((decision: ApprovalDecision) => { const resolver = approvalResolverRef.current diff --git a/packages/tui/src/cli.tsx b/packages/tui/src/cli.tsx index 3186fce..02ffe1c 100644 --- a/packages/tui/src/cli.tsx +++ b/packages/tui/src/cli.tsx @@ -5,7 +5,6 @@ import { createInterface } from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' import { render } from 'ink' import { - createAgentSession, loadMemoConfig, resolveContextWindowForProvider, writeMemoConfig, @@ -19,6 +18,7 @@ import { import { App } from './App' import { findLocalPackageInfoSync } from './version' import { runMcpCommand } from './mcp' +import { createHttpAgentSession } from './http/http_agent_session' import { parseHistoryLog, type ParsedHistoryLog } from './controllers/history_parser' import { loadSessionHistoryEntries } from './controllers/session_history' import { parseArgs, type ParsedArgs } from './cli_args' @@ -99,8 +99,18 @@ async function loadPreviousSession( return parseHistoryLog(raw) } -function restoreHistoryMessages(session: { history: ChatMessage[] }, messages: ChatMessage[]) { +async function restoreHistoryMessages( + session: { + history: ChatMessage[] + restoreHistory?: (messages: ChatMessage[]) => Promise + }, + messages: ChatMessage[], +) { if (!messages.length) return + if (typeof session.restoreHistory === 'function') { + await session.restoreHistory(messages) + return + } const systemMessage = session.history[0] if (!systemMessage) return session.history.splice(0, session.history.length, systemMessage, ...messages) @@ -158,9 +168,14 @@ async function runPlainMode(parsed: ParsedArgs) { }, } - const session = await createAgentSession(deps, sessionOptions) + const session = await createHttpAgentSession(deps, { + ...sessionOptions, + providerName: provider.name, + cwd: process.cwd(), + toolPermissionMode: parsed.options.dangerous ? 'full' : 'once', + }) if (previousSession) { - restoreHistoryMessages(session, previousSession.messages) + await restoreHistoryMessages(session, previousSession.messages) console.log('[session] Continued from previous session context.') } diff --git a/packages/tui/src/http/core_server_client.ts b/packages/tui/src/http/core_server_client.ts new file mode 100644 index 0000000..272ebc1 --- /dev/null +++ b/packages/tui/src/http/core_server_client.ts @@ -0,0 +1,330 @@ +import { randomUUID } from 'node:crypto' +import { + startCoreHttpServer, + type ChatMessage, + type CoreHttpServerHandle, + type LiveSessionState, + type SseEventEnvelope, + type ToolPermissionMode, +} from '@memo/core' + +type ApiEnvelope = + | { success: true; data: T; meta: { requestId: string; timestamp: string } } + | { + success: false + error: { code: string; message: string; details?: unknown } + meta: { requestId: string; timestamp: string; path?: string } + } + +type CreateSessionRequest = { + sessionId?: string + providerName?: string + cwd?: string + toolPermissionMode?: ToolPermissionMode + activeMcpServers?: string[] +} + +type SubmitMessageResult = { + accepted: boolean + queueId: string + queued: number +} + +type CompactSessionResult = { + reason: string + status: string + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string + keptMessages: number +} + +type TurnFinalEvent = { + turn: number + step?: number + finalText: string + status: string + errorMessage?: string + turnUsage?: { + prompt: number + completion: number + total: number + } + tokenUsage?: { + prompt: number + completion: number + total: number + } +} + +type ApprovalDecision = 'once' | 'session' | 'deny' + +function resolveMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + return String(error || 'unknown error') +} + +function assertSuccessEnvelope(payload: unknown): T { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid API response: expected object') + } + const envelope = payload as ApiEnvelope + if (envelope.success !== true) { + const message = + (envelope as ApiEnvelope & { error?: { message?: string } }).error?.message || + 'API request failed' + throw new Error(message) + } + return envelope.data +} + +function parseSseFrame(frame: string): SseEventEnvelope | null { + let event: string | undefined + const dataLines: string[] = [] + + for (const rawLine of frame.split('\n')) { + const line = rawLine.trimEnd() + if (!line || line.startsWith(':')) continue + + const delimiter = line.indexOf(':') + const field = delimiter >= 0 ? line.slice(0, delimiter) : line + const value = delimiter >= 0 ? line.slice(delimiter + 1).trimStart() : '' + + if (field === 'event') { + event = value + continue + } + if (field === 'data') { + dataLines.push(value) + } + } + + if (dataLines.length === 0) return null + const rawData = dataLines.join('\n') + let parsed: unknown + try { + parsed = JSON.parse(rawData) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + + const envelope = parsed as SseEventEnvelope + if (!envelope.event && event) { + envelope.event = event + } + if (typeof envelope.event !== 'string') return null + return envelope +} + +async function decodeSseStream( + stream: ReadableStream, + onEvent: (event: SseEventEnvelope) => Promise | void, +): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + buffer = buffer.replace(/\r\n/g, '\n') + + let separator = buffer.indexOf('\n\n') + while (separator >= 0) { + const frame = buffer.slice(0, separator) + buffer = buffer.slice(separator + 2) + separator = buffer.indexOf('\n\n') + + const parsed = parseSseFrame(frame) + if (!parsed) continue + await onEvent(parsed) + } + } + } finally { + reader.releaseLock() + } +} + +export class CoreServerClient { + constructor( + readonly baseUrl: string, + private readonly accessToken: string, + ) {} + + static async fromPassword(baseUrl: string, password: string): Promise { + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ password }), + }) + if (!response.ok) { + throw new Error(`Login failed (${response.status})`) + } + const payload = assertSuccessEnvelope<{ accessToken: string }>(await response.json()) + return new CoreServerClient(baseUrl, payload.accessToken) + } + + async createSession(input: CreateSessionRequest): Promise { + return this.postJson('/api/chat/sessions', input) + } + + async restoreHistory( + sessionId: string, + messages: ChatMessage[], + ): Promise<{ restored: boolean; messages: number }> { + return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/history`, { + messages, + }) + } + + async submitMessage(sessionId: string, input: string): Promise { + return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/messages`, { + input, + }) + } + + async respondApproval( + sessionId: string, + fingerprint: string, + decision: ApprovalDecision, + ): Promise<{ recorded: boolean }> { + return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/approval`, { + fingerprint, + decision, + }) + } + + async closeSession(sessionId: string): Promise<{ removed: boolean }> { + return this.deleteJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}`) + } + + async getSession(sessionId: string): Promise { + return this.getJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}`) + } + + async cancelTurn(sessionId: string): Promise<{ cancelled: boolean }> { + return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, {}) + } + + async compactSession(sessionId: string): Promise { + return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, {}) + } + + subscribeSessionEvents( + sessionId: string, + onEvent: (event: SseEventEnvelope) => Promise | void, + ): { close: () => void; done: Promise } { + const controller = new AbortController() + const done = (async () => { + const response = await fetch( + `${this.baseUrl}/api/chat/sessions/${encodeURIComponent(sessionId)}/events`, + { + method: 'GET', + headers: this.authHeaders(), + signal: controller.signal, + }, + ) + if (!response.ok) { + throw new Error(`SSE subscribe failed (${response.status})`) + } + if (!response.body) { + throw new Error('SSE stream body is empty') + } + await decodeSseStream(response.body, onEvent) + })().catch((error) => { + if (controller.signal.aborted) return + throw error + }) + + return { + close: () => controller.abort(), + done, + } + } + + private async postJson(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + ...this.authHeaders(), + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error(`${path} failed (${response.status})`) + } + return assertSuccessEnvelope(await response.json()) + } + + private async getJson(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'GET', + headers: this.authHeaders(), + }) + if (!response.ok) { + throw new Error(`${path} failed (${response.status})`) + } + return assertSuccessEnvelope(await response.json()) + } + + private async deleteJson(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: this.authHeaders(), + }) + if (!response.ok) { + throw new Error(`${path} failed (${response.status})`) + } + return assertSuccessEnvelope(await response.json()) + } + + private authHeaders(): Record { + return { + Authorization: `Bearer ${this.accessToken}`, + } + } +} + +export async function createEmbeddedCoreServerClient(options?: { + host?: string + memoHome?: string + password?: string +}): Promise<{ + client: CoreServerClient + server: CoreHttpServerHandle + close: () => Promise +}> { + const password = options?.password?.trim() || `memo-${randomUUID()}` + const server = await startCoreHttpServer({ + host: options?.host || '127.0.0.1', + port: 0, + password, + memoHome: options?.memoHome, + }) + + try { + const client = await CoreServerClient.fromPassword(server.url, password) + return { + client, + server, + close: async () => { + await server.close() + }, + } + } catch (error) { + await server.close() + throw new Error(`Failed to initialize core server client: ${resolveMessage(error)}`) + } +} + +export type { TurnFinalEvent } diff --git a/packages/tui/src/http/http_agent_session.ts b/packages/tui/src/http/http_agent_session.ts new file mode 100644 index 0000000..88b562a --- /dev/null +++ b/packages/tui/src/http/http_agent_session.ts @@ -0,0 +1,546 @@ +import type { + AgentSession, + AgentSessionDeps, + AgentSessionOptions, + ChatMessage, + CompactReason, + CompactResult, + ContextUsagePhase, + TokenUsage, + ToolActionStatus, + ToolPermissionMode, + TurnResult, + TurnStatus, +} from '@memo/core' +import type { ApprovalDecision, ApprovalRequest, RiskLevel } from '@memo/tools/approval' +import type { LiveSessionState, SseEventEnvelope } from '@memo/core' +import { createEmbeddedCoreServerClient, type CoreServerClient } from './core_server_client' + +type PendingTurn = { + input: string + turn?: number + resolve: (result: TurnResult) => void + reject: (error: Error) => void +} + +type HttpAgentSession = AgentSession & { + restoreHistory: (messages: ChatMessage[]) => Promise +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function normalizeTurnStatus(value: unknown): TurnStatus { + if (value === 'ok' || value === 'error' || value === 'prompt_limit' || value === 'cancelled') { + return value + } + return 'error' +} + +function normalizeCompactStatus(value: unknown): CompactResult['status'] { + if (value === 'success' || value === 'failed' || value === 'skipped') { + return value + } + return 'failed' +} + +function normalizeContextPhase(value: unknown): ContextUsagePhase { + if (value === 'turn_start' || value === 'step_start' || value === 'post_compact') { + return value + } + return 'step_start' +} + +function parseTokenUsage(value: unknown): TokenUsage | undefined { + const record = asRecord(value) + if (!record) return undefined + const prompt = asNumber(record.prompt) + const completion = asNumber(record.completion) + const total = asNumber(record.total) + if (prompt === undefined || completion === undefined || total === undefined) { + return undefined + } + return { prompt, completion, total } +} + +function ensureTokenUsage(value: TokenUsage | undefined): TokenUsage { + return value ?? { prompt: 0, completion: 0, total: 0 } +} + +function resolveErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + return String(error || 'unknown error') +} + +function normalizeToolPermissionMode(options: AgentSessionOptions): ToolPermissionMode { + if ( + options.toolPermissionMode === 'none' || + options.toolPermissionMode === 'once' || + options.toolPermissionMode === 'full' + ) { + return options.toolPermissionMode + } + return options.dangerous ? 'full' : 'once' +} + +function normalizeRiskLevel(value: unknown): RiskLevel { + if (value === 'read' || value === 'write' || value === 'execute') { + return value + } + return 'write' +} + +function normalizeApprovalDecision(value: unknown): ApprovalDecision { + if (value === 'once' || value === 'session' || value === 'deny') { + return value + } + return 'deny' +} + +function parseToolActions(value: unknown): Array<{ tool: string; input: unknown }> | undefined { + if (!Array.isArray(value)) return undefined + + const actions = value + .map((item) => { + const record = asRecord(item) + if (!record) return null + const tool = asString(record.tool) + if (!tool) return null + return { tool, input: record.input } + }) + .filter((item): item is { tool: string; input: unknown } => Boolean(item)) + return actions.length > 0 ? actions : undefined +} + +class HttpBackedAgentSession implements HttpAgentSession { + readonly mode = 'interactive' as const + readonly history: ChatMessage[] = [{ role: 'system', content: '' }] + + readonly id: string + title?: string + historyFilePath?: string + + private closed = false + private closePromise: Promise | null = null + private pendingTurns: PendingTurn[] = [] + private currentTurn = 0 + private currentStep = 0 + private availableToolNames: string[] = [] + private currentContextTokens = 0 + private contextWindow = 0 + private streamError: Error | null = null + private readonly closeEvents: () => void + private readonly eventsDone: Promise + + constructor( + private readonly client: CoreServerClient, + private readonly deps: AgentSessionDeps, + private readonly release: () => Promise, + initialState: LiveSessionState, + ) { + this.id = initialState.id + this.applySnapshot(initialState) + + const subscription = this.client.subscribeSessionEvents(this.id, async (event) => { + await this.handleEvent(event) + }) + this.closeEvents = subscription.close + this.eventsDone = subscription.done + .then(() => { + if (this.closed) return + const error = new Error('session event stream closed unexpectedly') + this.streamError = error + this.rejectPendingTurns(error) + }) + .catch((error) => { + if (this.closed) return + const wrapped = new Error( + `session event stream failed: ${resolveErrorMessage(error)}`, + ) + this.streamError = wrapped + this.rejectPendingTurns(wrapped) + }) + } + + listToolNames(): string[] { + return [...this.availableToolNames] + } + + async restoreHistory(messages: ChatMessage[]): Promise { + if (this.closed) throw new Error('session already closed') + + const normalized = messages.filter((message) => message.role !== 'system') + await this.client.restoreHistory(this.id, normalized) + + const system = this.history[0] ?? { role: 'system', content: '' } + this.history.splice(0, this.history.length, system, ...normalized) + } + + async runTurn(input: string): Promise { + if (this.closed) throw new Error('session already closed') + if (this.streamError) throw this.streamError + + const trimmed = input.trim() + if (!trimmed) { + throw new Error('input is required') + } + + return new Promise((resolve, reject) => { + const pending: PendingTurn = { + input: trimmed, + resolve, + reject, + } + this.pendingTurns.push(pending) + + void this.client.submitMessage(this.id, trimmed).catch((error) => { + this.pendingTurns = this.pendingTurns.filter((item) => item !== pending) + reject(new Error(resolveErrorMessage(error))) + }) + }) + } + + cancelCurrentTurn(): void { + if (this.closed) return + void this.client.cancelTurn(this.id).catch(() => {}) + } + + async compactHistory(reason: CompactReason = 'manual'): Promise { + if (this.closed) throw new Error('session already closed') + + const response = await this.client.compactSession(this.id) + const result: CompactResult = { + reason, + status: normalizeCompactStatus(response.status), + beforeTokens: asNumber(response.beforeTokens) ?? this.currentContextTokens, + afterTokens: asNumber(response.afterTokens) ?? this.currentContextTokens, + thresholdTokens: asNumber(response.thresholdTokens) ?? 0, + reductionPercent: asNumber(response.reductionPercent) ?? 0, + summary: asString(response.summary), + errorMessage: asString(response.errorMessage), + } + + this.currentContextTokens = result.afterTokens + return result + } + + async close(): Promise { + if (this.closePromise) return this.closePromise + + this.closePromise = (async () => { + this.closed = true + this.closeEvents() + this.rejectPendingTurns(new Error('session closed')) + + try { + await this.client.closeSession(this.id) + } catch { + // Best-effort close; server may already be down. + } + + await this.eventsDone + await this.release() + })() + + return this.closePromise + } + + private applySnapshot(state: LiveSessionState): void { + this.title = state.title + this.historyFilePath = state.historyFilePath + this.availableToolNames = state.availableToolNames ?? [] + this.currentContextTokens = state.currentContextTokens ?? this.currentContextTokens + this.contextWindow = state.contextWindow ?? this.contextWindow + } + + private rejectPendingTurns(error: Error): void { + const pending = this.pendingTurns + this.pendingTurns = [] + for (const item of pending) { + item.reject(error) + } + } + + private async handleEvent(envelope: SseEventEnvelope): Promise { + if (this.closed) return + const payload = asRecord(envelope.data) + if (!payload) return + + switch (envelope.event) { + case 'session.snapshot': { + this.applySnapshot(payload as unknown as LiveSessionState) + return + } + case 'turn.start': { + const turn = asNumber(payload.turn) + const input = asString(payload.input) ?? '' + const promptTokens = asNumber(payload.promptTokens) + if (turn !== undefined) { + this.currentTurn = turn + this.currentStep = 0 + const pending = this.pendingTurns.find((item) => item.turn === undefined) + if (pending) pending.turn = turn + } + + this.history.push({ role: 'user', content: input }) + await this.deps.hooks?.onTurnStart?.({ + sessionId: this.id, + turn: this.currentTurn, + input, + promptTokens, + history: this.history, + }) + return + } + case 'assistant.chunk': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) ?? this.currentStep + const chunk = asString(payload.chunk) ?? '' + this.currentTurn = turn + this.currentStep = step + if (chunk) { + this.deps.onAssistantStep?.(chunk, step) + } + return + } + case 'context.usage': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) ?? this.currentStep + const promptTokens = asNumber(payload.promptTokens) ?? this.currentContextTokens + const contextWindow = asNumber(payload.contextWindow) ?? this.contextWindow + const thresholdTokens = asNumber(payload.thresholdTokens) ?? 0 + const usagePercent = asNumber(payload.usagePercent) ?? 0 + const phase = normalizeContextPhase(payload.phase) + + this.currentTurn = turn + this.currentStep = step + this.currentContextTokens = promptTokens + this.contextWindow = contextWindow + + await this.deps.hooks?.onContextUsage?.({ + sessionId: this.id, + turn, + step, + promptTokens, + contextWindow, + thresholdTokens, + usagePercent, + phase, + }) + return + } + case 'tool.action': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) ?? this.currentStep + const actionRecord = asRecord(payload.action) + const actionTool = actionRecord ? asString(actionRecord.tool) : undefined + if (!actionTool) return + + this.currentTurn = turn + this.currentStep = step + + await this.deps.hooks?.onAction?.({ + sessionId: this.id, + turn, + step, + action: { + tool: actionTool, + input: actionRecord?.input, + }, + parallelActions: parseToolActions(payload.parallelActions), + thinking: asString(payload.thinking), + history: this.history, + }) + return + } + case 'context.compact': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) ?? this.currentStep + const result: CompactResult = { + reason: payload.reason === 'auto' ? 'auto' : 'manual', + status: normalizeCompactStatus(payload.status), + beforeTokens: asNumber(payload.beforeTokens) ?? this.currentContextTokens, + afterTokens: asNumber(payload.afterTokens) ?? this.currentContextTokens, + thresholdTokens: asNumber(payload.thresholdTokens) ?? 0, + reductionPercent: asNumber(payload.reductionPercent) ?? 0, + summary: asString(payload.summary), + errorMessage: asString(payload.errorMessage), + } + + this.currentTurn = turn + this.currentStep = step + this.currentContextTokens = result.afterTokens + await this.deps.hooks?.onContextCompacted?.({ + sessionId: this.id, + turn, + step, + ...result, + }) + return + } + case 'tool.observation': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) ?? this.currentStep + const observation = asString(payload.observation) ?? '' + this.currentTurn = turn + this.currentStep = step + + const resultStatus = asString(payload.resultStatus) as ToolActionStatus | undefined + const parallelResultStatuses = Array.isArray(payload.parallelResultStatuses) + ? payload.parallelResultStatuses.filter( + (item): item is ToolActionStatus => typeof item === 'string', + ) + : undefined + + await this.deps.hooks?.onObservation?.({ + sessionId: this.id, + turn, + step, + tool: 'unknown', + observation, + resultStatus, + parallelResultStatuses, + history: this.history, + }) + return + } + case 'approval.request': { + await this.handleApprovalRequest(payload) + return + } + case 'turn.final': { + const turn = asNumber(payload.turn) ?? this.currentTurn + const step = asNumber(payload.step) + const finalText = asString(payload.finalText) ?? '' + const status = normalizeTurnStatus(payload.status) + const errorMessage = asString(payload.errorMessage) + const tokenUsage = + parseTokenUsage(payload.tokenUsage) ?? parseTokenUsage(payload.turnUsage) + + this.currentTurn = turn + if (step !== undefined) { + this.currentStep = step + } + + if (finalText) { + this.history.push({ role: 'assistant', content: finalText }) + } + + await this.deps.hooks?.onFinal?.({ + sessionId: this.id, + turn, + step, + finalText, + status, + errorMessage, + tokenUsage, + turnUsage: ensureTokenUsage(tokenUsage), + steps: [], + }) + + const pending = + this.pendingTurns.find((item) => item.turn === turn) ?? this.pendingTurns[0] + if (pending) { + this.pendingTurns = this.pendingTurns.filter((item) => item !== pending) + pending.resolve({ + finalText, + steps: [], + status, + errorMessage, + tokenUsage: ensureTokenUsage(tokenUsage), + }) + } + return + } + case 'error': { + const message = asString(payload.message) ?? 'unknown error' + const pending = this.pendingTurns.shift() + if (pending) { + pending.reject(new Error(message)) + } + return + } + default: + return + } + } + + private async handleApprovalRequest(payload: Record): Promise { + const fingerprint = asString(payload.fingerprint) + const toolName = asString(payload.toolName) + const reason = asString(payload.reason) + if (!fingerprint || !toolName || !reason) return + + const request: ApprovalRequest = { + fingerprint, + toolName, + reason, + riskLevel: normalizeRiskLevel(payload.riskLevel), + params: payload.params, + } + + await this.deps.hooks?.onApprovalRequest?.({ + sessionId: this.id, + turn: this.currentTurn, + step: this.currentStep, + request, + }) + + let decision: ApprovalDecision = 'deny' + try { + if (this.deps.requestApproval) { + const userDecision = await this.deps.requestApproval(request) + decision = normalizeApprovalDecision(userDecision) + } + } catch { + decision = 'deny' + } + + await this.client.respondApproval(this.id, fingerprint, decision) + await this.deps.hooks?.onApprovalResponse?.({ + sessionId: this.id, + turn: this.currentTurn, + step: this.currentStep, + fingerprint, + decision, + }) + } +} + +export async function createHttpAgentSession( + deps: AgentSessionDeps, + options: AgentSessionOptions, +): Promise { + const embedded = await createEmbeddedCoreServerClient({ + memoHome: process.env.MEMO_HOME, + }) + + try { + const state = await embedded.client.createSession({ + sessionId: options.sessionId, + providerName: options.providerName, + cwd: options.cwd, + toolPermissionMode: normalizeToolPermissionMode(options), + activeMcpServers: options.activeMcpServers, + }) + + return new HttpBackedAgentSession(embedded.client, deps, embedded.close, state) + } catch (error) { + await embedded.close() + throw new Error(`Failed to create HTTP-backed session: ${resolveErrorMessage(error)}`) + } +} + +export type { HttpAgentSession } diff --git a/packages/tui/src/web/run_web_command.test.ts b/packages/tui/src/web/run_web_command.test.ts new file mode 100644 index 0000000..2e9a1d4 --- /dev/null +++ b/packages/tui/src/web/run_web_command.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const { startCoreHttpServerMock } = vi.hoisted(() => ({ + startCoreHttpServerMock: vi.fn(), +})) + +vi.mock('@memo/core', () => ({ + startCoreHttpServer: startCoreHttpServerMock, +})) + +import { runWebCommand } from './run_web_command' + +async function reserveAvailablePort(): Promise { + return new Promise((resolvePort, rejectPort) => { + const server = createServer() + server.once('error', rejectPort) + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => rejectPort(new Error('failed to reserve port'))) + return + } + const port = address.port + server.close((error) => { + if (error) { + rejectPort(error) + return + } + resolvePort(port) + }) + }) + }) +} + +describe('runWebCommand', () => { + const originalPassword = process.env.MEMO_SERVER_PASSWORD + + beforeEach(() => { + startCoreHttpServerMock.mockReset() + }) + + afterEach(() => { + process.env.MEMO_SERVER_PASSWORD = originalPassword + vi.restoreAllMocks() + }) + + test('starts core server and closes on SIGTERM', async () => { + const staticDir = await mkdtemp(join(tmpdir(), 'memo-web-static-')) + await writeFile(join(staticDir, 'index.html'), '', 'utf8') + const preferredPort = await reserveAvailablePort() + + const closeMock = vi.fn(async () => {}) + startCoreHttpServerMock.mockResolvedValue({ + url: `http://127.0.0.1:${preferredPort}`, + openApiSpecPath: '/api/openapi.json', + close: closeMock, + }) + + process.env.MEMO_SERVER_PASSWORD = 'test-password' + + const signalHandlers = new Map void>() + const onceSpy = vi.spyOn(process, 'once').mockImplementation(((event, listener) => { + if ((event === 'SIGINT' || event === 'SIGTERM') && typeof listener === 'function') { + signalHandlers.set(event, listener as () => void) + } + return process + }) as typeof process.once) + + const commandPromise = runWebCommand([ + '--host', + '127.0.0.1', + '--port', + String(preferredPort), + '--static-dir', + staticDir, + '--no-open', + ]) + + await vi.waitFor(() => { + expect(startCoreHttpServerMock).toHaveBeenCalledTimes(1) + }) + + const terminate = signalHandlers.get('SIGTERM') + expect(typeof terminate).toBe('function') + terminate?.() + + await commandPromise + + expect(startCoreHttpServerMock).toHaveBeenCalledWith({ + host: '127.0.0.1', + port: preferredPort, + password: 'test-password', + staticDir, + }) + expect(closeMock).toHaveBeenCalledTimes(1) + onceSpy.mockRestore() + }) +}) diff --git a/packages/tui/src/web/run_web_command.ts b/packages/tui/src/web/run_web_command.ts index 227dfc1..b146843 100644 --- a/packages/tui/src/web/run_web_command.ts +++ b/packages/tui/src/web/run_web_command.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createServer } from 'node:net' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { startCoreHttpServer } from '@memo/core' import { parseWebArgs } from './cli_web_args' const DEFAULT_HOST = '127.0.0.1' @@ -36,33 +37,6 @@ function hasFile(path: string): boolean { return existsSync(path) } -function resolveServerEntry(explicitPath?: string): string | null { - const candidates: string[] = [] - if (explicitPath) candidates.push(resolve(explicitPath)) - if (process.env.MEMO_WEB_SERVER_ENTRY) { - candidates.push(resolve(process.env.MEMO_WEB_SERVER_ENTRY)) - } - - const runtimeDir = dirname(fileURLToPath(import.meta.url)) - const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) - if (packageRoot) { - candidates.push(join(packageRoot, 'dist/web/server/main.cjs')) - candidates.push(join(packageRoot, 'dist/web/server/main.js')) - candidates.push(join(packageRoot, 'packages/web-server/dist/main.cjs')) - candidates.push(join(packageRoot, 'packages/web-server/dist/main.js')) - } - - candidates.push(join(process.cwd(), 'dist/web/server/main.cjs')) - candidates.push(join(process.cwd(), 'dist/web/server/main.js')) - candidates.push(join(process.cwd(), 'packages/web-server/dist/main.cjs')) - candidates.push(join(process.cwd(), 'packages/web-server/dist/main.js')) - - for (const candidate of candidates) { - if (hasFile(candidate)) return candidate - } - return null -} - function resolveWebStaticDir(explicitPath?: string): string | null { const candidates: string[] = [] if (explicitPath) candidates.push(resolve(explicitPath)) @@ -86,45 +60,6 @@ function resolveWebStaticDir(explicitPath?: string): string | null { return null } -function resolveTaskPromptsDir(): string { - const runtimeDir = dirname(fileURLToPath(import.meta.url)) - const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) - const candidates: string[] = [] - if (packageRoot) { - candidates.push(join(packageRoot, 'dist/task-prompts')) - candidates.push(join(packageRoot, 'packages/tui/src/task-prompts')) - } - candidates.push(resolve(runtimeDir, '../task-prompts')) - candidates.push(resolve(runtimeDir, 'task-prompts')) - - for (const candidate of candidates) { - if (hasFile(join(candidate, 'init_agents.md'))) return candidate - } - - return candidates[0] ?? resolve(runtimeDir, '../task-prompts') -} - -function resolveSystemPromptPath(): string | null { - const runtimeDir = dirname(fileURLToPath(import.meta.url)) - const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) - const candidates: string[] = [] - if (process.env.MEMO_SYSTEM_PROMPT_PATH) { - candidates.push(resolve(process.env.MEMO_SYSTEM_PROMPT_PATH)) - } - if (packageRoot) { - candidates.push(join(packageRoot, 'dist/prompt.md')) - candidates.push(join(packageRoot, 'packages/core/src/runtime/prompt.md')) - } - candidates.push(resolve(runtimeDir, '../prompt.md')) - candidates.push(resolve(runtimeDir, '../../core/src/runtime/prompt.md')) - - for (const candidate of candidates) { - if (hasFile(candidate)) return candidate - } - - return null -} - async function isPortAvailable(host: string, port: number): Promise { return new Promise((resolveAvailable) => { const server = createServer() @@ -178,31 +113,12 @@ function formatAddress(host: string, port: number): string { return `http://${safeHost}:${port}` } -async function waitForServer(host: string, port: number, timeoutMs = 8000): Promise { - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - if (!(await isPortAvailable(host, port))) { - return true - } - await new Promise((resolveDelay) => setTimeout(resolveDelay, 120)) - } - return false -} - export async function runWebCommand(argv: string[]): Promise { const options = parseWebArgs(argv) const host = options.host ?? DEFAULT_HOST const preferredPort = options.port ?? DEFAULT_PORT const port = await resolveAvailablePort(host, preferredPort) - const serverEntry = resolveServerEntry() - if (!serverEntry) { - console.error('web-server entry not found (main.js missing).') - console.error('Please run `pnpm run web:server:build` or `pnpm run build` first.') - process.exitCode = 1 - return - } - const staticDir = resolveWebStaticDir(options.staticDir) if (!staticDir) { console.error('web-ui static assets not found (index.html missing).') @@ -211,56 +127,61 @@ export async function runWebCommand(argv: string[]): Promise { return } + const password = process.env.MEMO_SERVER_PASSWORD?.trim() + if (!password) { + console.error('MEMO_SERVER_PASSWORD is required for `memo web`.') + console.error('Example: MEMO_SERVER_PASSWORD=your-password memo web') + process.exitCode = 1 + return + } + if (port !== preferredPort) { console.log(`[memo web] Port ${preferredPort} is busy, using ${port}`) } - const url = formatAddress(host, port) + + let handle: Awaited> | null = null + try { + handle = await startCoreHttpServer({ + host, + port, + password, + staticDir, + }) + } catch (error) { + console.error(`Failed to start core server: ${(error as Error).message}`) + process.exitCode = 1 + return + } + + const url = handle.url || formatAddress(host, port) console.log(`[memo web] Server: ${url}`) - console.log(`[memo web] Entry: ${serverEntry}`) console.log(`[memo web] Static: ${staticDir}`) - const taskPromptsDir = resolveTaskPromptsDir() - const systemPromptPath = resolveSystemPromptPath() + console.log(`[memo web] OpenAPI: ${url}${handle.openApiSpecPath}`) - const child = spawn(process.execPath, [serverEntry], { - stdio: 'inherit', - env: { - ...process.env, - MEMO_WEB_HOST: host, - MEMO_WEB_PORT: String(port), - MEMO_WEB_STATIC_DIR: staticDir, - MEMO_CLI_ENTRY: process.argv[1], - MEMO_TASK_PROMPTS_DIR: taskPromptsDir, - ...(systemPromptPath ? { MEMO_SYSTEM_PROMPT_PATH: systemPromptPath } : {}), - }, - }) - - if (options.open) { - const ready = await waitForServer(host, port) - if (!ready || !openBrowser(url)) { - console.warn(`[memo web] Failed to auto-open browser. Open manually: ${url}`) - } + if (options.open && !openBrowser(url)) { + console.warn(`[memo web] Failed to auto-open browser. Open manually: ${url}`) } - const forwardSignal = (signal: NodeJS.Signals) => { - if (!child.killed) child.kill(signal) + const shutdown = async () => { + if (!handle) return + const toClose = handle + handle = null + await toClose.close() } - process.once('SIGINT', () => { - forwardSignal('SIGINT') - }) - process.once('SIGTERM', () => { - forwardSignal('SIGTERM') - }) + await new Promise((resolveDone) => { + const onSignal = (signal: NodeJS.Signals) => { + void shutdown() + .catch((error) => { + console.error( + `Failed to stop core server on ${signal}: ${(error as Error).message}`, + ) + process.exitCode = 1 + }) + .finally(() => resolveDone()) + } - await new Promise((resolve) => { - child.once('exit', (code, signal) => { - if (signal) { - process.exitCode = 0 - resolve() - return - } - process.exitCode = code ?? 0 - resolve() - }) + process.once('SIGINT', () => onSignal('SIGINT')) + process.once('SIGTERM', () => onSignal('SIGTERM')) }) } diff --git a/packages/web-server/.gitignore b/packages/web-server/.gitignore deleted file mode 100644 index 4b56acf..0000000 --- a/packages/web-server/.gitignore +++ /dev/null @@ -1,56 +0,0 @@ -# compiled output -/dist -/node_modules -/build - -# Logs -logs -*.log -npm-debug.log* -pnpm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# OS -.DS_Store - -# Tests -/coverage -/.nyc_output - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# temp directory -.temp -.tmp - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/packages/web-server/.prettierrc b/packages/web-server/.prettierrc deleted file mode 100644 index a20502b..0000000 --- a/packages/web-server/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all" -} diff --git a/packages/web-server/eslint.config.mjs b/packages/web-server/eslint.config.mjs deleted file mode 100644 index d45aa03..0000000 --- a/packages/web-server/eslint.config.mjs +++ /dev/null @@ -1,35 +0,0 @@ -// @ts-check -import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['eslint.config.mjs'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], - }, - }, -); diff --git a/packages/web-server/nest-cli.json b/packages/web-server/nest-cli.json deleted file mode 100644 index f9aa683..0000000 --- a/packages/web-server/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/packages/web-server/package.json b/packages/web-server/package.json deleted file mode 100644 index 6254d00..0000000 --- a/packages/web-server/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@memo-code/web-server", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "prebuild": "pnpm --filter @memo-code/core build", - "predev": "pnpm --filter @memo-code/core build", - "dev": "nest start --watch", - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" - }, - "dependencies": { - "@memo-code/core": "workspace:*", - "@nestjs/common": "^11.0.1", - "@nestjs/core": "^11.0.1", - "@nestjs/jwt": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", - "openai": "^6.10.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "toml": "^3.0.0", - "ws": "^8.18.3", - "yaml": "^2.8.1" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^22.10.7", - "@types/supertest": "^6.0.2", - "@types/ws": "^8.5.17", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", - "prettier": "^3.4.2", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" - } -} diff --git a/packages/web-server/src/app.controller.ts b/packages/web-server/src/app.controller.ts deleted file mode 100644 index 49dba14..0000000 --- a/packages/web-server/src/app.controller.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { Public } from './auth/public.decorator'; - -@Controller() -export class AppController { - @Public() - @Get('healthz') - healthz() { - return { - status: 'ok', - service: 'memo-web-server', - }; - } -} diff --git a/packages/web-server/src/app.module.ts b/packages/web-server/src/app.module.ts deleted file mode 100644 index 18518c3..0000000 --- a/packages/web-server/src/app.module.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; -import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; -import { AccessTokenGuard } from './auth/access-token.guard'; -import { AuthModule } from './auth/auth.module'; -import { AppController } from './app.controller'; -import { ApiErrorFilter } from './common/filters/api-error.filter'; -import { ApiResponseInterceptor } from './common/interceptors/api-response.interceptor'; -import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; -import { ServerConfigModule } from './config/server-config.module'; -import { ChatModule } from './chat/chat.module'; -import { McpModule } from './mcp/mcp.module'; -import { SessionsModule } from './sessions/sessions.module'; -import { SkillsModule } from './skills/skills.module'; -import { StreamModule } from './stream/stream.module'; -import { WsGatewayModule } from './ws/ws-gateway.module'; -import { WorkspacesModule } from './workspaces/workspaces.module'; - -@Module({ - imports: [ - ServerConfigModule, - WorkspacesModule, - AuthModule, - SessionsModule, - StreamModule, - ChatModule, - McpModule, - SkillsModule, - WsGatewayModule, - ], - controllers: [AppController], - providers: [ - { - provide: APP_GUARD, - useClass: AccessTokenGuard, - }, - { - provide: APP_INTERCEPTOR, - useClass: ApiResponseInterceptor, - }, - { - provide: APP_FILTER, - useClass: ApiErrorFilter, - }, - ], -}) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer): void { - consumer.apply(RequestLoggingMiddleware).forRoutes('*'); - } -} diff --git a/packages/web-server/src/auth/access-token.guard.ts b/packages/web-server/src/auth/access-token.guard.ts deleted file mode 100644 index 3cf5c5f..0000000 --- a/packages/web-server/src/auth/access-token.guard.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import type { AuthenticatedRequest } from './auth.types'; -import { IS_PUBLIC_ROUTE } from './public.decorator'; -import { AuthService } from './auth.service'; - -function extractBearerToken(authorization: string | undefined): string { - if (!authorization) { - throw new UnauthorizedException('Missing Authorization header'); - } - - const [scheme, token] = authorization.split(' '); - if (scheme !== 'Bearer' || !token) { - throw new UnauthorizedException('Invalid Authorization header'); - } - return token.trim(); -} - -@Injectable() -export class AccessTokenGuard implements CanActivate { - constructor( - private readonly reflector: Reflector, - private readonly authService: AuthService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const isPublic = this.reflector.getAllAndOverride( - IS_PUBLIC_ROUTE, - [context.getHandler(), context.getClass()], - ); - if (isPublic) return true; - - const request = context.switchToHttp().getRequest(); - const token = extractBearerToken(request.headers.authorization); - const payload = await this.authService.verifyAccessToken(token); - request.user = { - username: payload.sub, - tokenId: payload.jti, - }; - return true; - } -} diff --git a/packages/web-server/src/auth/auth.controller.ts b/packages/web-server/src/auth/auth.controller.ts deleted file mode 100644 index 4746266..0000000 --- a/packages/web-server/src/auth/auth.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; -import { Public } from './public.decorator'; -import { AuthService } from './auth.service'; - -type LoginBody = { - username?: unknown; - password?: unknown; -}; - -type RefreshBody = { - refreshToken?: unknown; -}; - -function requiredString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new BadRequestException(`${field} is required`); - } - const trimmed = value.trim(); - if (!trimmed) { - throw new BadRequestException(`${field} is required`); - } - return trimmed; -} - -@Public() -@Controller('api/auth') -export class AuthController { - constructor(private readonly authService: AuthService) {} - - @Post('login') - async login(@Body() body: LoginBody) { - const username = requiredString(body.username, 'username'); - const password = requiredString(body.password, 'password'); - return this.authService.login(username, password); - } - - @Post('refresh') - async refresh(@Body() body: RefreshBody) { - const refreshToken = requiredString(body.refreshToken, 'refreshToken'); - return this.authService.refresh(refreshToken); - } - - @Post('logout') - async logout(@Body() body: RefreshBody) { - const refreshToken = requiredString(body.refreshToken, 'refreshToken'); - await this.authService.revokeRefreshToken(refreshToken); - return { loggedOut: true }; - } -} diff --git a/packages/web-server/src/auth/auth.module.ts b/packages/web-server/src/auth/auth.module.ts deleted file mode 100644 index 16507d9..0000000 --- a/packages/web-server/src/auth/auth.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; - -@Module({ - imports: [JwtModule.register({})], - controllers: [AuthController], - providers: [AuthService], - exports: [AuthService], -}) -export class AuthModule {} diff --git a/packages/web-server/src/auth/auth.service.ts b/packages/web-server/src/auth/auth.service.ts deleted file mode 100644 index 3fcafad..0000000 --- a/packages/web-server/src/auth/auth.service.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID, timingSafeEqual } from 'node:crypto'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { ServerConfigService } from '../config/server-config.service'; -import type { ServerAuthConfig } from '../config/server-config.types'; -import type { - AccessTokenPayload, - AuthTokenPair, - RefreshTokenPayload, -} from './auth.types'; - -type RefreshTokenRecord = { - username: string; - expiresAt: number; -}; - -function secureEqual(expected: string, actual: string): boolean { - const expectedBuffer = Buffer.from(expected); - const actualBuffer = Buffer.from(actual); - if (expectedBuffer.length !== actualBuffer.length) return false; - return timingSafeEqual(expectedBuffer, actualBuffer); -} - -@Injectable() -export class AuthService { - private readonly refreshTokenStore = new Map(); - - constructor( - private readonly jwtService: JwtService, - private readonly serverConfigService: ServerConfigService, - ) {} - - async login(username: string, password: string): Promise { - const config = await this.serverConfigService.load(); - const auth = config.auth; - - if ( - !secureEqual(auth.username, username) || - !secureEqual(auth.password, password) - ) { - throw new UnauthorizedException('Invalid username or password'); - } - - return this.issueTokenPair(auth.username, auth); - } - - async refresh(refreshToken: string): Promise { - const config = await this.serverConfigService.load(); - const auth = config.auth; - this.pruneExpiredRefreshTokens(); - - let payload: RefreshTokenPayload; - try { - payload = this.jwtService.verify(refreshToken, { - secret: auth.refreshTokenSecret, - }); - } catch { - throw new UnauthorizedException('Invalid refresh token'); - } - - if (payload.type !== 'refresh' || !payload.sub || !payload.jti) { - throw new UnauthorizedException('Invalid refresh token'); - } - - const record = this.refreshTokenStore.get(payload.jti); - if ( - !record || - record.username !== payload.sub || - record.expiresAt <= Date.now() - ) { - this.refreshTokenStore.delete(payload.jti); - throw new UnauthorizedException('Refresh token expired or revoked'); - } - - this.refreshTokenStore.delete(payload.jti); - return this.issueTokenPair(payload.sub, auth); - } - - async revokeRefreshToken(refreshToken: string): Promise { - const config = await this.serverConfigService.load(); - let payload: RefreshTokenPayload | null = null; - try { - payload = this.jwtService.verify(refreshToken, { - secret: config.auth.refreshTokenSecret, - }); - } catch { - return; - } - if (payload?.jti) { - this.refreshTokenStore.delete(payload.jti); - } - } - - async verifyAccessToken(accessToken: string): Promise { - const config = await this.serverConfigService.load(); - let payload: AccessTokenPayload; - try { - payload = this.jwtService.verify(accessToken, { - secret: config.auth.accessTokenSecret, - }); - } catch { - throw new UnauthorizedException('Invalid access token'); - } - - if (payload.type !== 'access' || !payload.sub || !payload.jti) { - throw new UnauthorizedException('Invalid access token'); - } - return payload; - } - - private issueTokenPair( - username: string, - auth: ServerAuthConfig, - ): AuthTokenPair { - this.pruneExpiredRefreshTokens(); - - const accessJti = randomUUID(); - const refreshJti = randomUUID(); - const now = Date.now(); - const refreshExpiresAt = now + auth.refreshTokenTtlSeconds * 1000; - - const accessToken = this.jwtService.sign( - { - sub: username, - type: 'access', - jti: accessJti, - } satisfies AccessTokenPayload, - { - secret: auth.accessTokenSecret, - expiresIn: auth.accessTokenTtlSeconds, - }, - ); - - const refreshToken = this.jwtService.sign( - { - sub: username, - type: 'refresh', - jti: refreshJti, - } satisfies RefreshTokenPayload, - { - secret: auth.refreshTokenSecret, - expiresIn: auth.refreshTokenTtlSeconds, - }, - ); - - this.refreshTokenStore.set(refreshJti, { - username, - expiresAt: refreshExpiresAt, - }); - - return { - tokenType: 'Bearer', - accessToken, - refreshToken, - accessTokenExpiresIn: auth.accessTokenTtlSeconds, - refreshTokenExpiresIn: auth.refreshTokenTtlSeconds, - }; - } - - private pruneExpiredRefreshTokens(): void { - const now = Date.now(); - for (const [jti, record] of this.refreshTokenStore.entries()) { - if (record.expiresAt <= now) { - this.refreshTokenStore.delete(jti); - } - } - } -} diff --git a/packages/web-server/src/auth/auth.types.ts b/packages/web-server/src/auth/auth.types.ts deleted file mode 100644 index 2df5cd9..0000000 --- a/packages/web-server/src/auth/auth.types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Request } from 'express'; - -export type AccessTokenPayload = { - sub: string; - type: 'access'; - jti: string; -}; - -export type RefreshTokenPayload = { - sub: string; - type: 'refresh'; - jti: string; -}; - -export type AuthTokenPair = { - tokenType: 'Bearer'; - accessToken: string; - refreshToken: string; - accessTokenExpiresIn: number; - refreshTokenExpiresIn: number; -}; - -export type RequestUser = { - username: string; - tokenId: string; -}; - -export type AuthenticatedRequest = Request & { - requestId?: string; - user?: RequestUser; -}; diff --git a/packages/web-server/src/auth/public.decorator.ts b/packages/web-server/src/auth/public.decorator.ts deleted file mode 100644 index 8ba1631..0000000 --- a/packages/web-server/src/auth/public.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const IS_PUBLIC_ROUTE = 'isPublicRoute'; -export const Public = () => SetMetadata(IS_PUBLIC_ROUTE, true); diff --git a/packages/web-server/src/chat/chat.controller.ts b/packages/web-server/src/chat/chat.controller.ts deleted file mode 100644 index 9c1c245..0000000 --- a/packages/web-server/src/chat/chat.controller.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - BadRequestException, - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, -} from '@nestjs/common'; -import { ChatService } from './chat.service'; -import type { CreateLiveSessionInput } from './chat.types'; - -type SubmitInputBody = { - input?: unknown; -}; - -type ApprovalBody = { - decision?: unknown; -}; - -function asString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new BadRequestException(`${field} is required`); - } - const trimmed = value.trim(); - if (!trimmed) { - throw new BadRequestException(`${field} is required`); - } - return trimmed; -} - -function parseCreateBody( - body: Record, -): CreateLiveSessionInput { - const mode = body.toolPermissionMode; - const activeMcp = body.activeMcpServers; - return { - providerName: - typeof body.providerName === 'string' ? body.providerName : undefined, - workspaceId: - typeof body.workspaceId === 'string' ? body.workspaceId : undefined, - cwd: typeof body.cwd === 'string' ? body.cwd : undefined, - toolPermissionMode: - mode === 'none' || mode === 'once' || mode === 'full' ? mode : undefined, - activeMcpServers: Array.isArray(activeMcp) - ? activeMcp.filter((item): item is string => typeof item === 'string') - : undefined, - }; -} - -@Controller('api/chat/sessions') -export class ChatController { - constructor(private readonly chatService: ChatService) {} - - @Post() - async createSession(@Body() body: Record) { - return this.chatService.createSession(parseCreateBody(body)); - } - - @Get('providers') - async listProviders() { - return this.chatService.listProviders(); - } - - @Get('runtimes') - async listRuntimes(@Query() query: Record) { - return this.chatService.listRuntimeBadges({ - workspaceId: - typeof query.workspaceId === 'string' ? query.workspaceId : undefined, - }); - } - - @Get(':id') - async getSession(@Param('id') sessionId: string) { - return this.chatService.getSessionState(sessionId); - } - - @Delete(':id') - async deleteSession(@Param('id') sessionId: string) { - return this.chatService.closeSession(sessionId); - } - - @Post(':id/input') - async submitInput( - @Param('id') sessionId: string, - @Body() body: SubmitInputBody, - ) { - const input = asString(body.input, 'input'); - return this.chatService.submitInput(sessionId, input); - } - - @Post(':id/cancel') - async cancel(@Param('id') sessionId: string) { - return this.chatService.cancelCurrentTurn(sessionId); - } - - @Post(':id/compact') - async compact(@Param('id') sessionId: string) { - return this.chatService.compactSession(sessionId); - } - - @Post(':id/approvals/:fingerprint') - async approvalDecision( - @Param('id') sessionId: string, - @Param('fingerprint') fingerprint: string, - @Body() body: ApprovalBody, - ) { - const decision = body.decision; - if (decision !== 'once' && decision !== 'session' && decision !== 'deny') { - throw new BadRequestException('decision must be once | session | deny'); - } - return this.chatService.applyApprovalDecision( - sessionId, - fingerprint, - decision, - ); - } -} diff --git a/packages/web-server/src/chat/chat.module.ts b/packages/web-server/src/chat/chat.module.ts deleted file mode 100644 index e44ee45..0000000 --- a/packages/web-server/src/chat/chat.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SessionsModule } from '../sessions/sessions.module'; -import { StreamModule } from '../stream/stream.module'; -import { WorkspacesModule } from '../workspaces/workspaces.module'; -import { ChatController } from './chat.controller'; -import { ChatService } from './chat.service'; - -@Module({ - imports: [StreamModule, SessionsModule, WorkspacesModule], - controllers: [ChatController], - providers: [ChatService], - exports: [ChatService], -}) -export class ChatModule {} diff --git a/packages/web-server/src/chat/chat.service.ts b/packages/web-server/src/chat/chat.service.ts deleted file mode 100644 index 7c76730..0000000 --- a/packages/web-server/src/chat/chat.service.ts +++ /dev/null @@ -1,1259 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { access, readFile } from 'node:fs/promises'; -import { resolve, join } from 'node:path'; -import { - BadRequestException, - Injectable, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { - createAgentSession, - getFileSuggestions, - type FileSuggestion, - JsonlHistorySink, - type QueuedInputItem, - resolveSlashCommand, - resolveContextWindowForProvider, - type AgentSession, - type AgentSessionDeps, - type ApprovalDecision, - type ApprovalRequest, - type ChatMessage, - type SessionTurnStep, - type ToolPermissionMode, -} from '@memo-code/core'; -import { MemoConfigService } from '../config/memo-config.service'; -import type { - MemoProviderConfig, - MemoRuntimeConfig, -} from '../config/memo-config.types'; -import { SessionsService } from '../sessions/sessions.service'; -import { StreamService } from '../stream/stream.service'; -import { WorkspacesService } from '../workspaces/workspaces.service'; -import type { - ChatFileSuggestionsResponse, - ChatProviderRecord, - ChatRuntimeListResponse, - ChatSessionSnapshot, - ChatSnapshotTurn, - CreateLiveSessionInput, - LiveSessionState, - SessionInputResult, -} from './chat.types'; - -type InternalTurnRecord = { - turn: number; - input: string; - assistant: string; - status: string; - errorMessage?: string; - steps: SessionTurnStep[]; -}; - -type InternalSession = { - id: string; - title: string; - workspaceId: string; - projectName: string; - providerName: string; - model: string; - cwd: string; - startedAt: string; - updatedAt: string; - status: 'idle' | 'running' | 'closed'; - activeMcpServers: string[]; - toolPermissionMode: 'none' | 'once' | 'full'; - turn: number; - historyFilePath?: string; - turns: InternalTurnRecord[]; - pendingApprovals: Map void>; - pendingApproval?: ApprovalRequest; - nextInputDisplay?: string; - activeTurn?: number; - currentContextTokens: number; - contextWindow: number; - queuedInputs: QueuedInputItem[]; - queueDraining: boolean; - agentSession: AgentSession; -}; - -const MAX_LIVE_SESSIONS = 20; -const MAX_QUEUED_INPUTS = 3; - -function normalizeMode(value: unknown): 'none' | 'once' | 'full' { - if (value === 'none' || value === 'once' || value === 'full') return value; - return 'once'; -} - -function toAssistantText(turn: { - finalText?: string; - steps: Array<{ assistantText?: string }>; -}): string { - const finalText = turn.finalText?.trim(); - if (finalText) return finalText; - return turn.steps - .map((step) => step.assistantText ?? '') - .join('') - .trim(); -} - -function cloneTurnStep(step: SessionTurnStep): SessionTurnStep { - return { - step: step.step, - assistantText: step.assistantText, - thinking: step.thinking, - action: step.action, - parallelActions: step.parallelActions, - observation: step.observation, - resultStatus: step.resultStatus, - }; -} - -function cloneTurnSteps( - steps: SessionTurnStep[] | undefined, -): SessionTurnStep[] { - if (!steps || steps.length === 0) return []; - return steps.map(cloneTurnStep); -} - -async function findTaskPromptTemplate(templateName: string): Promise { - const candidates = [ - process.env.MEMO_TASK_PROMPTS_DIR - ? join(process.env.MEMO_TASK_PROMPTS_DIR, `${templateName}.md`) - : null, - join(process.cwd(), 'dist', 'task-prompts', `${templateName}.md`), - join( - process.cwd(), - 'packages', - 'tui', - 'src', - 'task-prompts', - `${templateName}.md`, - ), - ].filter((item): item is string => Boolean(item)); - - for (const filePath of candidates) { - const resolved = resolve(filePath); - try { - await access(resolved); - return readFile(resolved, 'utf8'); - } catch { - // Try next candidate. - } - } - - throw new Error(`Task prompt not found: ${templateName}`); -} - -function renderTemplate( - template: string, - vars: Record, -): string { - return template.replace(/{{\s*([\w.-]+)\s*}}/g, (_match, key: string) => { - return vars[key] ?? ''; - }); -} - -@Injectable() -export class ChatService { - private readonly sessions = new Map(); - - constructor( - private readonly streamService: StreamService, - private readonly memoConfigService: MemoConfigService, - private readonly sessionsService: SessionsService, - private readonly workspacesService: WorkspacesService, - ) {} - - async createSession( - input: CreateLiveSessionInput, - ): Promise { - if (this.sessions.size >= MAX_LIVE_SESSIONS) { - await this.evictOneIdleSession(); - } - - const workspace = await this.workspacesService.resolveWorkspace({ - workspaceId: input.workspaceId, - cwd: input.cwd, - }); - await this.workspacesService.touchLastUsed(workspace.id); - - const config = await this.memoConfigService.load(); - const provider = this.selectProvider( - config.providers, - input.providerName, - config.current_provider, - ); - const contextWindow = resolveContextWindowForProvider(config, provider); - - const sessionId = randomUUID(); - const runtime = await this.createRuntime({ - id: sessionId, - title: 'New Session', - workspaceId: workspace.id, - projectName: workspace.name, - providerName: provider.name, - model: provider.model, - cwd: workspace.cwd, - startedAt: new Date().toISOString(), - activeMcpServers: - input.activeMcpServers && input.activeMcpServers.length > 0 - ? input.activeMcpServers - : config.active_mcp_servers, - toolPermissionMode: normalizeMode(input.toolPermissionMode), - contextWindow, - }); - - this.sessions.set(runtime.id, runtime); - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: this.toState(runtime), - }); - return this.toState(runtime); - } - - async attachSession(sessionId: string): Promise { - const existing = this.sessions.get(sessionId); - if (existing) { - return this.toSnapshot(existing); - } - - const detail = await this.sessionsService.getSessionDetail(sessionId); - const workspace = await this.workspacesService.ensureByCwd( - detail.cwd || process.cwd(), - detail.project, - { validateReadable: false }, - ); - await this.workspacesService.touchLastUsed(workspace.id); - - const config = await this.memoConfigService.load(); - const provider = this.selectProvider( - config.providers, - undefined, - config.current_provider, - ); - const contextWindow = resolveContextWindowForProvider(config, provider); - - const historyMessages: ChatMessage[] = []; - const turns: InternalTurnRecord[] = []; - - for (const turn of detail.turns) { - const input = (turn.input ?? '').trim(); - const assistant = toAssistantText(turn); - if (input) { - historyMessages.push({ role: 'user', content: input }); - } - if (assistant) { - historyMessages.push({ role: 'assistant', content: assistant }); - } - turns.push({ - turn: turn.turn, - input, - assistant, - status: turn.status ?? 'ok', - errorMessage: turn.errorMessage, - steps: cloneTurnSteps(turn.steps), - }); - } - - const maxTurn = turns.reduce((max, item) => Math.max(max, item.turn), 0); - - const runtime = await this.createRuntime({ - id: detail.sessionId, - title: detail.title, - workspaceId: workspace.id, - projectName: workspace.name, - providerName: provider.name, - model: provider.model, - cwd: workspace.cwd, - startedAt: detail.date.startedAt, - activeMcpServers: config.active_mcp_servers, - toolPermissionMode: 'once', - historyFilePath: detail.filePath, - contextWindow, - }); - - const system = runtime.agentSession.history[0]; - runtime.agentSession.history = system - ? [system, ...historyMessages] - : [...historyMessages]; - (runtime.agentSession as unknown as { turnIndex: number }).turnIndex = - maxTurn; - ( - runtime.agentSession as unknown as { sessionStartEmitted: boolean } - ).sessionStartEmitted = true; - runtime.agentSession.title = detail.title; - - runtime.turn = maxTurn; - runtime.turns = turns; - - this.sessions.set(runtime.id, runtime); - this.touchSession(runtime); - - const snapshot = this.toSnapshot(runtime); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: snapshot.state, - }); - - return snapshot; - } - - getSessionState(sessionId: string): LiveSessionState { - const session = this.requireSession(sessionId); - return this.toState(session); - } - - getSessionSnapshot(sessionId: string): ChatSessionSnapshot { - const session = this.requireSession(sessionId); - return this.toSnapshot(session); - } - - async listProviders(): Promise { - const config = await this.memoConfigService.load(); - const current = config.current_provider || config.providers[0]?.name || ''; - return config.providers.map((provider) => ({ - name: provider.name, - model: provider.model, - isCurrent: provider.name === current, - })); - } - - async listRuntimeBadges(input: { - workspaceId?: string; - }): Promise { - const workspaceId = input.workspaceId?.trim(); - const items = Array.from(this.sessions.values()) - .filter((session) => !workspaceId || session.workspaceId === workspaceId) - .map((session) => ({ - sessionId: session.id, - workspaceId: session.workspaceId, - status: session.status, - updatedAt: session.updatedAt, - })) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - return { items }; - } - - async suggestFiles(input: { - query: string; - sessionId?: string; - workspaceId?: string; - limit?: number; - }): Promise { - const sessionId = input.sessionId?.trim(); - const workspaceId = input.workspaceId?.trim(); - - if (!sessionId && !workspaceId) { - throw new BadRequestException('sessionId or workspaceId is required'); - } - - let cwd = ''; - if (sessionId) { - const active = this.sessions.get(sessionId); - if (active) { - cwd = active.cwd; - } else { - const detail = await this.sessionsService.getSessionDetail(sessionId); - cwd = detail.cwd || process.cwd(); - } - } else if (workspaceId) { - const workspace = await this.workspacesService.resolveWorkspace({ - workspaceId, - }); - cwd = workspace.cwd; - } - - const limit = - typeof input.limit === 'number' - ? Math.max(1, Math.min(20, Math.floor(input.limit))) - : 8; - const items = await getFileSuggestions({ - cwd, - query: input.query ?? '', - limit, - respectGitIgnore: true, - }); - - return { - items: items.map( - (item): FileSuggestion => ({ - id: item.id, - path: item.path, - name: item.name, - parent: item.parent, - isDir: item.isDir, - }), - ), - }; - } - - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - async deleteSession(sessionId: string): Promise<{ deleted: true }> { - const target = sessionId.trim(); - if (!target) { - throw new BadRequestException('sessionId is required'); - } - - if (this.sessions.has(target)) { - await this.closeSession(target); - } - - try { - await this.sessionsService.removeSession(target); - } catch (error) { - if (!(error instanceof NotFoundException)) { - throw error; - } - } - - return { deleted: true }; - } - - async closeSession(sessionId: string): Promise<{ closed: true }> { - const session = this.requireSession(sessionId); - - this.resolvePendingApprovals(session, 'deny'); - - session.status = 'closed'; - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.status', - payload: { - status: 'closed', - workspaceId: session.workspaceId, - updatedAt: session.updatedAt, - }, - }); - - await session.agentSession.close(); - this.sessions.delete(sessionId); - this.streamService.disconnectSession(sessionId); - - return { closed: true }; - } - - async submitInput( - sessionId: string, - input: string, - ): Promise { - const session = this.requireSession(sessionId); - const trimmed = input.trim(); - if (!trimmed) { - throw new BadRequestException('input is required'); - } - - if (session.status === 'running') { - if (session.queuedInputs.length >= MAX_QUEUED_INPUTS) { - return { - accepted: false, - kind: 'turn', - status: 'error', - message: `Queue is full (max ${MAX_QUEUED_INPUTS}).`, - }; - } - - session.queuedInputs.push({ - id: randomUUID(), - input: trimmed, - createdAt: new Date().toISOString(), - }); - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - return { - accepted: true, - kind: 'turn', - status: 'ok', - }; - } - - if (trimmed.startsWith('/')) { - return this.runSlashCommand(session, trimmed); - } - - return this.runCoreTurn(session, trimmed, trimmed); - } - - removeQueuedInput(sessionId: string, queueId: string): { removed: boolean } { - const session = this.requireSession(sessionId); - const targetQueueId = queueId.trim(); - if (!targetQueueId) { - throw new BadRequestException('queueId is required'); - } - - const next = session.queuedInputs.filter( - (item) => item.id !== targetQueueId, - ); - if (next.length === session.queuedInputs.length) { - return { removed: false }; - } - - session.queuedInputs = next; - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - return { removed: true }; - } - - sendQueuedInputNow(sessionId: string): { triggered: boolean } { - const session = this.requireSession(sessionId); - if (session.queuedInputs.length === 0) { - return { triggered: false }; - } - - if (session.status === 'running') { - this.cancelCurrentTurn(sessionId); - return { triggered: true }; - } - - void this.drainQueuedInputs(session); - return { triggered: true }; - } - - cancelCurrentTurn(sessionId: string): { cancelled: boolean } { - const session = this.requireSession(sessionId); - if (session.status === 'running') { - session.agentSession.cancelCurrentTurn?.('cancelled by user'); - return { cancelled: true }; - } - return { cancelled: false }; - } - - async compactSession( - sessionId: string, - ): Promise<{ compacted: boolean; keptMessages: number }> { - const session = this.requireSession(sessionId); - const result = await session.agentSession.compactHistory('manual'); - session.currentContextTokens = Math.max(0, result.afterTokens); - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - - this.streamService.broadcast(session.id, { - type: 'system.message', - payload: { - title: 'Compact', - content: - result.status === 'success' - ? `Compacted history to ${result.afterTokens} tokens.` - : result.status === 'skipped' - ? 'Skipped compaction: no history to compact.' - : `Compaction failed: ${result.errorMessage ?? 'unknown error'}`, - }, - }); - - return { - compacted: result.status === 'success', - keptMessages: session.agentSession.history.length, - }; - } - - applyApprovalDecision( - sessionId: string, - fingerprint: string, - decision: 'once' | 'session' | 'deny', - ): { recorded: boolean } { - const session = this.requireSession(sessionId); - const resolver = session.pendingApprovals.get(fingerprint); - if (!resolver) { - return { recorded: false }; - } - - session.pendingApprovals.delete(fingerprint); - if (session.pendingApproval?.fingerprint === fingerprint) { - session.pendingApproval = undefined; - } - resolver(decision); - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - return { recorded: true }; - } - - private async runSlashCommand( - session: InternalSession, - input: string, - ): Promise { - const config = await this.memoConfigService.load(); - const result = resolveSlashCommand(input, { - configPath: this.memoConfigService.getConfigPath(), - providerName: session.providerName, - model: session.model, - mcpServers: config.mcp_servers, - providers: config.providers, - toolPermissionMode: session.toolPermissionMode, - }); - - if (result.kind === 'message') { - this.sendSystemMessage(session, result.title, result.content); - return { accepted: true, kind: 'command', status: 'ok' }; - } - - if (result.kind === 'exit') { - await this.closeSession(session.id); - return { - accepted: true, - kind: 'command', - status: 'ok', - message: 'session closed', - }; - } - - if (result.kind === 'new') { - await this.recreateSessionRuntime(session, { - title: 'New Session', - resetTurns: true, - }); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - this.sendSystemMessage( - session, - 'New Session', - 'Started a fresh session.', - ); - return { accepted: true, kind: 'command', status: 'ok' }; - } - - if (result.kind === 'switch_model') { - await this.recreateSessionRuntime(session, { - title: session.title, - providerName: result.provider.name, - model: result.provider.model, - }); - this.sendSystemMessage( - session, - 'Models', - `Switched to ${result.provider.name} (${result.provider.model}).`, - ); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - return { accepted: true, kind: 'command', status: 'ok' }; - } - - if (result.kind === 'set_tool_permission') { - await this.recreateSessionRuntime(session, { - title: session.title, - toolPermissionMode: result.mode, - }); - this.sendSystemMessage( - session, - 'Tools', - `Tool permission set to ${result.mode}.`, - ); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - return { accepted: true, kind: 'command', status: 'ok' }; - } - - if (result.kind === 'compact') { - await this.compactSession(session.id); - return { accepted: true, kind: 'command', status: 'ok' }; - } - - if (result.kind === 'init_agents_md') { - const template = await findTaskPromptTemplate('init_agents'); - const prompt = renderTemplate(template, {}); - return this.runCoreTurn(session, prompt, '/init'); - } - - if (result.kind === 'review_pr') { - const prNumber = String(result.prNumber); - const template = await findTaskPromptTemplate('review_pull_request'); - const prompt = renderTemplate(template, { - pr_number: prNumber, - backend_strategy: 'web_server', - backend_details: 'Running from memo web server', - mcp_server_prefix: 'github', - }); - return this.runCoreTurn(session, prompt, `/review ${prNumber}`); - } - - return { accepted: true, kind: 'command', status: 'ok' }; - } - - private async runCoreTurn( - session: InternalSession, - prompt: string, - displayInput: string, - ): Promise { - session.nextInputDisplay = displayInput; - - try { - const result = await session.agentSession.runTurn(prompt); - return { - accepted: true, - kind: 'turn', - status: - result.status === 'ok' - ? 'ok' - : result.status === 'cancelled' - ? 'cancelled' - : 'error', - message: result.errorMessage, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - session.status = 'idle'; - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'error', - payload: { - code: 'turn_error', - message, - }, - }); - this.streamService.broadcast(session.id, { - type: 'session.status', - payload: { - status: 'idle', - workspaceId: session.workspaceId, - updatedAt: session.updatedAt, - }, - }); - return { - accepted: true, - kind: 'turn', - status: 'error', - message, - }; - } - } - - private async drainQueuedInputs(session: InternalSession): Promise { - if (session.status !== 'idle') return; - if (session.queueDraining) return; - if (session.queuedInputs.length === 0) return; - - session.queueDraining = true; - try { - while (session.status === 'idle' && session.queuedInputs.length > 0) { - const next = session.queuedInputs.shift(); - if (!next) break; - - this.touchSession(session); - this.streamService.broadcast(session.id, { - type: 'session.snapshot', - payload: this.toState(session), - }); - - const input = next.input.trim(); - if (!input) continue; - - if (input.startsWith('/')) { - await this.runSlashCommand(session, input); - } else { - await this.runCoreTurn(session, input, input); - } - } - } finally { - session.queueDraining = false; - } - } - - private async recreateSessionRuntime( - session: InternalSession, - options: { - providerName?: string; - model?: string; - toolPermissionMode?: 'none' | 'once' | 'full'; - activeMcpServers?: string[]; - title?: string; - resetTurns?: boolean; - }, - ): Promise { - this.resolvePendingApprovals(session, 'deny'); - await session.agentSession.close(); - const config = await this.memoConfigService.load(); - - if (options.providerName) session.providerName = options.providerName; - if (options.model) session.model = options.model; - if (options.toolPermissionMode) { - session.toolPermissionMode = options.toolPermissionMode; - } - if (options.activeMcpServers) { - session.activeMcpServers = options.activeMcpServers; - } - if (options.title) { - session.title = options.title; - } - - session.contextWindow = resolveContextWindowForProvider(config, { - name: session.providerName, - model: session.model, - }); - session.currentContextTokens = 0; - - if (options.resetTurns) { - session.turn = 0; - session.turns = []; - } - - session.agentSession = await this.createCoreSession(session); - if (!options.resetTurns) { - this.restoreAgentHistoryFromTurns(session); - } - session.status = 'idle'; - this.touchSession(session); - } - - private restoreAgentHistoryFromTurns(session: InternalSession): void { - if (!session.turns.length) return; - - const historyMessages: ChatMessage[] = []; - let maxTurn = 0; - - const sorted = [...session.turns].sort((a, b) => a.turn - b.turn); - for (const turn of sorted) { - maxTurn = Math.max(maxTurn, turn.turn); - const input = turn.input.trim(); - const assistant = turn.assistant.trim(); - if (input) { - historyMessages.push({ role: 'user', content: input }); - } - if (assistant) { - historyMessages.push({ role: 'assistant', content: assistant }); - } - } - - const system = session.agentSession.history[0]; - session.agentSession.history = system - ? [system, ...historyMessages] - : [...historyMessages]; - (session.agentSession as unknown as { turnIndex: number }).turnIndex = - maxTurn; - ( - session.agentSession as unknown as { sessionStartEmitted: boolean } - ).sessionStartEmitted = true; - session.agentSession.title = session.title; - session.turn = maxTurn; - } - - private async createRuntime(input: { - id: string; - title: string; - workspaceId: string; - projectName: string; - providerName: string; - model: string; - cwd: string; - startedAt: string; - activeMcpServers: string[]; - toolPermissionMode: 'none' | 'once' | 'full'; - contextWindow: number; - historyFilePath?: string; - }): Promise { - const runtime: InternalSession = { - id: input.id, - title: input.title, - workspaceId: input.workspaceId, - projectName: input.projectName, - providerName: input.providerName, - model: input.model, - cwd: input.cwd, - startedAt: input.startedAt, - updatedAt: new Date().toISOString(), - status: 'idle', - activeMcpServers: input.activeMcpServers, - toolPermissionMode: input.toolPermissionMode, - turn: 0, - historyFilePath: input.historyFilePath, - turns: [], - pendingApprovals: new Map void>(), - currentContextTokens: 0, - contextWindow: input.contextWindow, - queuedInputs: [], - queueDraining: false, - agentSession: null as unknown as AgentSession, - }; - - runtime.agentSession = await this.createCoreSession(runtime); - runtime.historyFilePath = - runtime.historyFilePath ?? runtime.agentSession.historyFilePath; - - return runtime; - } - - private async createCoreSession( - runtime: InternalSession, - ): Promise { - const deps: AgentSessionDeps = { - historySinks: runtime.historyFilePath - ? [new JsonlHistorySink(runtime.historyFilePath)] - : undefined, - onAssistantStep: (chunk, step) => { - if (!chunk) return; - const turn = runtime.activeTurn; - if (!turn) return; - - const record = this.getOrCreateTurnRecord(runtime, turn); - const stepRecord = this.getOrCreateTurnStep(record, step); - record.assistant = `${record.assistant}${chunk}`; - stepRecord.assistantText = `${stepRecord.assistantText ?? ''}${chunk}`; - - this.streamService.broadcast(runtime.id, { - type: 'assistant.chunk', - payload: { - turn, - step, - chunk, - }, - }); - }, - requestApproval: async (request) => { - runtime.pendingApproval = request; - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'approval.request', - payload: { - fingerprint: request.fingerprint, - toolName: request.toolName, - reason: request.reason, - riskLevel: request.riskLevel, - params: request.params, - }, - }); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: this.toState(runtime), - }); - - return new Promise((resolve) => { - runtime.pendingApprovals.set(request.fingerprint, resolve); - }); - }, - hooks: { - onTurnStart: ({ turn, input, promptTokens }) => { - runtime.status = 'running'; - runtime.activeTurn = turn; - runtime.turn = Math.max(runtime.turn, turn); - runtime.currentContextTokens = - typeof promptTokens === 'number' && Number.isFinite(promptTokens) - ? Math.max(0, promptTokens) - : runtime.currentContextTokens; - - const displayInput = runtime.nextInputDisplay ?? input; - runtime.nextInputDisplay = undefined; - - const record = this.getOrCreateTurnRecord(runtime, turn); - record.input = displayInput; - record.assistant = ''; - record.status = 'running'; - record.errorMessage = undefined; - record.steps = []; - - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'session.status', - payload: { - status: 'running', - workspaceId: runtime.workspaceId, - updatedAt: runtime.updatedAt, - }, - }); - this.streamService.broadcast(runtime.id, { - type: 'turn.start', - payload: { - turn, - input: displayInput, - promptTokens, - }, - }); - }, - onContextUsage: ({ - turn, - step, - phase, - promptTokens, - contextWindow, - thresholdTokens, - usagePercent, - }) => { - runtime.currentContextTokens = Math.max(0, promptTokens); - runtime.contextWindow = Math.max(0, contextWindow); - this.streamService.broadcast(runtime.id, { - type: 'context.usage', - payload: { - turn, - step, - phase, - promptTokens, - contextWindow, - thresholdTokens, - usagePercent, - }, - }); - }, - onAction: ({ turn, step, action, parallelActions, thinking }) => { - const record = this.getOrCreateTurnRecord(runtime, turn); - const stepRecord = this.getOrCreateTurnStep(record, step); - stepRecord.action = action; - stepRecord.parallelActions = - parallelActions && parallelActions.length > 1 - ? parallelActions - : undefined; - stepRecord.thinking = thinking; - - this.streamService.broadcast(runtime.id, { - type: 'tool.action', - payload: { - turn, - step, - action, - parallelActions: - parallelActions && parallelActions.length > 0 - ? parallelActions - : undefined, - thinking, - }, - }); - }, - onObservation: ({ - turn, - step, - observation, - resultStatus, - parallelResultStatuses, - }) => { - const record = this.getOrCreateTurnRecord(runtime, turn); - const stepRecord = this.getOrCreateTurnStep(record, step); - stepRecord.observation = observation; - stepRecord.resultStatus = - typeof resultStatus === 'string' - ? resultStatus - : parallelResultStatuses?.[0]; - - this.streamService.broadcast(runtime.id, { - type: 'tool.observation', - payload: { - turn, - step, - observation, - resultStatus, - parallelResultStatuses, - }, - }); - }, - onFinal: ({ turn, finalText, status, errorMessage }) => { - runtime.activeTurn = undefined; - runtime.status = 'idle'; - - const record = this.getOrCreateTurnRecord(runtime, turn); - record.assistant = finalText || record.assistant; - record.status = status; - record.errorMessage = errorMessage; - - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'turn.final', - payload: { - turn, - finalText, - status, - errorMessage, - }, - }); - this.streamService.broadcast(runtime.id, { - type: 'session.status', - payload: { - status: 'idle', - workspaceId: runtime.workspaceId, - updatedAt: runtime.updatedAt, - }, - }); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: this.toState(runtime), - }); - void this.drainQueuedInputs(runtime); - }, - onTitleGenerated: ({ title }) => { - runtime.title = title; - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: this.toState(runtime), - }); - }, - onApprovalResponse: ({ fingerprint }) => { - runtime.pendingApprovals.delete(fingerprint); - if (runtime.pendingApproval?.fingerprint === fingerprint) { - runtime.pendingApproval = undefined; - } - this.touchSession(runtime); - this.streamService.broadcast(runtime.id, { - type: 'session.snapshot', - payload: this.toState(runtime), - }); - }, - }, - }; - - return createAgentSession(deps, { - sessionId: runtime.id, - mode: 'interactive', - providerName: runtime.providerName, - contextWindow: runtime.contextWindow, - activeMcpServers: runtime.activeMcpServers, - toolPermissionMode: runtime.toolPermissionMode as ToolPermissionMode, - dangerous: runtime.toolPermissionMode === 'full', - cwd: runtime.cwd, - }); - } - - private getOrCreateTurnRecord( - runtime: InternalSession, - turn: number, - ): InternalTurnRecord { - let record = runtime.turns.find((item) => item.turn === turn); - if (record) return record; - - record = { - turn, - input: '', - assistant: '', - status: 'running', - steps: [], - }; - runtime.turns.push(record); - return record; - } - - private getOrCreateTurnStep( - record: InternalTurnRecord, - step: number, - ): SessionTurnStep { - let entry = record.steps.find((item) => item.step === step); - if (entry) return entry; - entry = { step }; - record.steps.push(entry); - record.steps.sort((a, b) => a.step - b.step); - return entry; - } - - private resolvePendingApprovals( - session: InternalSession, - decision: ApprovalDecision, - ): void { - for (const resolver of session.pendingApprovals.values()) { - resolver(decision); - } - session.pendingApprovals.clear(); - session.pendingApproval = undefined; - } - - private sendSystemMessage( - session: InternalSession, - title: string, - content: string, - ): void { - this.streamService.broadcast(session.id, { - type: 'system.message', - payload: { - title, - content, - }, - }); - } - - private touchSession(session: InternalSession): void { - session.updatedAt = new Date().toISOString(); - } - - private requireSession(sessionId: string): InternalSession { - const session = this.sessions.get(sessionId); - if (!session) { - throw new NotFoundException('Live session not found'); - } - return session; - } - - private toState(session: InternalSession): LiveSessionState { - return { - id: session.id, - title: session.title, - workspaceId: session.workspaceId, - projectName: session.projectName, - providerName: session.providerName, - model: session.model, - cwd: session.cwd, - startedAt: session.startedAt, - status: session.status, - pendingApproval: session.pendingApproval - ? { - fingerprint: session.pendingApproval.fingerprint, - toolName: session.pendingApproval.toolName, - reason: session.pendingApproval.reason, - riskLevel: session.pendingApproval.riskLevel, - params: session.pendingApproval.params, - } - : undefined, - activeMcpServers: session.activeMcpServers, - toolPermissionMode: session.toolPermissionMode, - queuedInputs: session.queuedInputs.map((item) => ({ ...item })), - currentContextTokens: session.currentContextTokens, - contextWindow: session.contextWindow, - }; - } - - private toSnapshot(session: InternalSession): ChatSessionSnapshot { - return { - state: this.toState(session), - turns: session.turns.map( - (turn): ChatSnapshotTurn => ({ - turn: turn.turn, - input: turn.input, - assistant: turn.assistant, - status: turn.status, - errorMessage: turn.errorMessage, - steps: cloneTurnSteps(turn.steps), - }), - ), - }; - } - - private selectProvider( - providers: MemoProviderConfig[], - preferredName: string | undefined, - fallbackName: string, - ): MemoProviderConfig { - const candidate = preferredName || fallbackName; - const found = providers.find((provider) => provider.name === candidate); - if (found) return found; - if (providers.length > 0) return providers[0] as MemoProviderConfig; - throw new ServiceUnavailableException( - 'No providers configured in memo config.', - ); - } - - private async evictOneIdleSession(): Promise { - const idle = Array.from(this.sessions.values()).find( - (session) => session.status === 'idle', - ); - if (!idle) { - throw new ServiceUnavailableException( - `Too many live sessions (max=${MAX_LIVE_SESSIONS}).`, - ); - } - await this.closeSession(idle.id); - } -} diff --git a/packages/web-server/src/chat/chat.types.ts b/packages/web-server/src/chat/chat.types.ts deleted file mode 100644 index e85200d..0000000 --- a/packages/web-server/src/chat/chat.types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - FileSuggestion, - LiveSessionState, - SessionRuntimeBadge, - SessionTurnStep, -} from '@memo-code/core'; - -export type ChatSnapshotTurn = { - turn: number; - input: string; - assistant: string; - status: string; - errorMessage?: string; - steps?: SessionTurnStep[]; -}; - -export type ChatSessionSnapshot = { - state: LiveSessionState; - turns: ChatSnapshotTurn[]; -}; - -export type CreateLiveSessionInput = { - providerName?: string; - workspaceId?: string; - cwd?: string; - toolPermissionMode?: 'none' | 'once' | 'full'; - activeMcpServers?: string[]; -}; - -export type ChatProviderRecord = { - name: string; - model: string; - isCurrent: boolean; -}; - -export type SessionInputResult = { - accepted: boolean; - kind: 'turn' | 'command'; - status: 'ok' | 'error' | 'cancelled'; - message?: string; -}; - -export type ChatRuntimeListResponse = { - items: SessionRuntimeBadge[]; -}; - -export type ChatFileSuggestionsResponse = { - items: FileSuggestion[]; -}; - -export type { LiveSessionState }; diff --git a/packages/web-server/src/common/constants.ts b/packages/web-server/src/common/constants.ts deleted file mode 100644 index ed8d8a0..0000000 --- a/packages/web-server/src/common/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const REQUEST_ID_HEADER = 'x-request-id'; diff --git a/packages/web-server/src/common/filters/api-error.filter.ts b/packages/web-server/src/common/filters/api-error.filter.ts deleted file mode 100644 index 6b35f7b..0000000 --- a/packages/web-server/src/common/filters/api-error.filter.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, - Logger, -} from '@nestjs/common'; -import type { Response } from 'express'; -import type { AuthenticatedRequest } from '../../auth/auth.types'; -import { REQUEST_ID_HEADER } from '../constants'; - -type ErrorPayload = { - code: string; - message: string; - details?: unknown; -}; - -@Catch() -export class ApiErrorFilter implements ExceptionFilter { - private readonly logger = new Logger(ApiErrorFilter.name); - - catch(exception: unknown, host: ArgumentsHost): void { - const http = host.switchToHttp(); - const request = http.getRequest(); - const response = http.getResponse(); - - const requestId = - request.requestId || - (typeof response.locals?.requestId === 'string' - ? response.locals.requestId - : undefined) || - (typeof response.getHeader(REQUEST_ID_HEADER) === 'string' - ? (response.getHeader(REQUEST_ID_HEADER) as string) - : undefined) || - 'unknown'; - - let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; - let payload: ErrorPayload = { - code: 'INTERNAL_SERVER_ERROR', - message: 'Internal server error', - }; - - if (exception instanceof HttpException) { - statusCode = exception.getStatus(); - payload = this.parseHttpException(exception, statusCode); - } else if (exception instanceof Error) { - payload = { - code: 'INTERNAL_SERVER_ERROR', - message: exception.message || 'Internal server error', - }; - } - - this.logger.error( - `${request.method} ${request.originalUrl} -> ${statusCode} reqId=${requestId} message="${payload.message}"`, - exception instanceof Error ? exception.stack : undefined, - ); - - response.status(statusCode).json({ - success: false, - error: payload, - meta: { - requestId, - timestamp: new Date().toISOString(), - path: request.originalUrl, - }, - }); - } - - private parseHttpException( - exception: HttpException, - statusCode: number, - ): ErrorPayload { - const fallbackCode = - typeof HttpStatus[statusCode] === 'string' - ? String(HttpStatus[statusCode]) - : 'HTTP_EXCEPTION'; - const fallbackMessage = exception.message || 'Request failed'; - - const response = exception.getResponse(); - if (typeof response === 'string') { - return { - code: fallbackCode, - message: response, - }; - } - - if (!response || typeof response !== 'object') { - return { - code: fallbackCode, - message: fallbackMessage, - }; - } - - const record = response as Record; - const message = this.normalizeMessage(record.message) ?? fallbackMessage; - const code = typeof record.code === 'string' ? record.code : fallbackCode; - const details = record.details; - return { - code, - message, - ...(details === undefined ? {} : { details }), - }; - } - - private normalizeMessage(raw: unknown): string | null { - if (typeof raw === 'string') return raw; - if (Array.isArray(raw)) { - const items = raw.filter( - (item): item is string => typeof item === 'string', - ); - if (items.length > 0) return items.join('; '); - } - return null; - } -} diff --git a/packages/web-server/src/common/interceptors/api-response.interceptor.ts b/packages/web-server/src/common/interceptors/api-response.interceptor.ts deleted file mode 100644 index fb43c7b..0000000 --- a/packages/web-server/src/common/interceptors/api-response.interceptor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - NestInterceptor, - StreamableFile, -} from '@nestjs/common'; -import type { Response } from 'express'; -import { map, type Observable } from 'rxjs'; -import type { AuthenticatedRequest } from '../../auth/auth.types'; -import { REQUEST_ID_HEADER } from '../constants'; - -type ApiSuccessMeta = { - requestId: string; - timestamp: string; -}; - -type ApiSuccessEnvelope = { - success: true; - data: T; - meta: ApiSuccessMeta; -}; - -function resolveRequestId( - request: AuthenticatedRequest, - response: Response, -): string { - if (request.requestId && request.requestId.trim().length > 0) - return request.requestId; - const fromLocals = (response.locals as { requestId?: unknown } | undefined) - ?.requestId; - if (typeof fromLocals === 'string' && fromLocals.trim().length > 0) - return fromLocals; - const fromHeader = response.getHeader(REQUEST_ID_HEADER); - if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) - return fromHeader; - return 'unknown'; -} - -@Injectable() -export class ApiResponseInterceptor implements NestInterceptor< - T, - ApiSuccessEnvelope | StreamableFile -> { - intercept( - context: ExecutionContext, - next: CallHandler, - ): Observable | StreamableFile> { - if (context.getType() !== 'http') { - return next.handle() as Observable>; - } - - const http = context.switchToHttp(); - const request = http.getRequest(); - const response = http.getResponse(); - const requestId = resolveRequestId(request, response); - - return next.handle().pipe( - map((data) => { - if (data instanceof StreamableFile) return data; - return { - success: true, - data: (data ?? null) as T, - meta: { - requestId, - timestamp: new Date().toISOString(), - }, - }; - }), - ); - } -} diff --git a/packages/web-server/src/common/middleware/request-logging.middleware.ts b/packages/web-server/src/common/middleware/request-logging.middleware.ts deleted file mode 100644 index 4c6d3a2..0000000 --- a/packages/web-server/src/common/middleware/request-logging.middleware.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { Injectable, Logger, type NestMiddleware } from '@nestjs/common'; -import type { NextFunction, Response } from 'express'; -import type { AuthenticatedRequest } from '../../auth/auth.types'; -import { REQUEST_ID_HEADER } from '../constants'; - -function readHeaderValue(value: string | string[] | undefined): string | null { - if (!value) return null; - if (Array.isArray(value)) { - const first = value[0]?.trim(); - return first && first.length > 0 ? first : null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -@Injectable() -export class RequestLoggingMiddleware implements NestMiddleware { - private readonly logger = new Logger('HTTP'); - - use( - request: AuthenticatedRequest, - response: Response, - next: NextFunction, - ): void { - const existingRequestId = readHeaderValue( - request.headers[REQUEST_ID_HEADER], - ); - const requestId = existingRequestId ?? randomUUID(); - const startedAt = Date.now(); - - request.requestId = requestId; - response.locals.requestId = requestId; - response.setHeader(REQUEST_ID_HEADER, requestId); - - response.on('finish', () => { - const durationMs = Date.now() - startedAt; - this.logger.log( - `${request.method} ${request.originalUrl} ${response.statusCode} ${durationMs}ms reqId=${requestId}`, - ); - }); - - next(); - } -} diff --git a/packages/web-server/src/config/memo-config.service.ts b/packages/web-server/src/config/memo-config.service.ts deleted file mode 100644 index c0ae3af..0000000 --- a/packages/web-server/src/config/memo-config.service.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { Injectable } from '@nestjs/common'; -import { - loadMemoConfig, - writeMemoConfig, - type MemoConfig, - type ProviderConfig, -} from '@memo-code/core'; -import type { MemoRuntimeConfig } from './memo-config.types'; - -const DEFAULT_PROVIDER: ProviderConfig = { - name: 'deepseek', - env_api_key: 'DEEPSEEK_API_KEY', - model: 'deepseek-chat', - base_url: 'https://api.deepseek.com', -}; - -function expandHome(path: string): string { - if (!path.startsWith('~')) return path; - return join(homedir(), path.slice(1)); -} - -function resolveMemoHome(): string { - const memoHome = process.env.MEMO_HOME; - if (!memoHome) return join(homedir(), '.memo'); - return expandHome(memoHome); -} - -function readNonEmptyString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function normalizeProviders(input: unknown): ProviderConfig[] { - if (!Array.isArray(input)) { - return [DEFAULT_PROVIDER]; - } - - const providers: ProviderConfig[] = []; - for (const item of input) { - if (!item || typeof item !== 'object' || Array.isArray(item)) continue; - const record = item as Record; - const name = readNonEmptyString(record.name); - if (!name) continue; - - providers.push({ - name, - env_api_key: - readNonEmptyString(record.env_api_key) ?? DEFAULT_PROVIDER.env_api_key, - model: readNonEmptyString(record.model) ?? DEFAULT_PROVIDER.model, - base_url: readNonEmptyString(record.base_url), - }); - } - - return providers.length > 0 ? providers : [DEFAULT_PROVIDER]; -} - -function normalizeCurrentProvider( - currentProvider: unknown, - providers: ProviderConfig[], -): string { - const current = readNonEmptyString(currentProvider); - if (current && providers.some((provider) => provider.name === current)) { - return current; - } - return providers[0]?.name ?? DEFAULT_PROVIDER.name; -} - -function normalizeActiveMcpServers(input: unknown): string[] { - if (!Array.isArray(input)) return []; - return Array.from( - new Set( - input - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean), - ), - ); -} - -function normalizeMcpServers(input: unknown): MemoRuntimeConfig['mcp_servers'] { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - return {}; - } - return input as MemoRuntimeConfig['mcp_servers']; -} - -@Injectable() -export class MemoConfigService { - private readonly configPath = join(resolveMemoHome(), 'config.toml'); - - getConfigPath(): string { - return this.configPath; - } - - async load(): Promise { - const loaded = await loadMemoConfig(); - const providers = normalizeProviders(loaded.config.providers); - - return { - current_provider: normalizeCurrentProvider( - loaded.config.current_provider, - providers, - ), - providers, - mcp_servers: normalizeMcpServers(loaded.config.mcp_servers), - active_mcp_servers: normalizeActiveMcpServers( - loaded.config.active_mcp_servers, - ), - }; - } - - async setActiveMcpServers(names: string[]): Promise { - const loaded = await loadMemoConfig(); - const nextConfig: MemoConfig = { - ...loaded.config, - active_mcp_servers: normalizeActiveMcpServers(names), - }; - await writeMemoConfig(loaded.configPath, nextConfig); - } -} diff --git a/packages/web-server/src/config/memo-config.types.ts b/packages/web-server/src/config/memo-config.types.ts deleted file mode 100644 index 4e21266..0000000 --- a/packages/web-server/src/config/memo-config.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { - MCPServerConfig as MemoMcpServerConfig, - MemoConfig, - ProviderConfig as MemoProviderConfig, -} from '@memo-code/core'; - -export type { MemoMcpServerConfig, MemoProviderConfig }; - -export type MemoRuntimeConfig = Pick< - MemoConfig, - 'current_provider' | 'providers' | 'model_profiles' -> & { - mcp_servers: Record; - active_mcp_servers: string[]; -}; diff --git a/packages/web-server/src/config/server-config.module.ts b/packages/web-server/src/config/server-config.module.ts deleted file mode 100644 index 6d82e35..0000000 --- a/packages/web-server/src/config/server-config.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { MemoConfigService } from './memo-config.service'; -import { ServerConfigService } from './server-config.service'; - -@Global() -@Module({ - providers: [ServerConfigService, MemoConfigService], - exports: [ServerConfigService, MemoConfigService], -}) -export class ServerConfigModule {} diff --git a/packages/web-server/src/config/server-config.service.test.ts b/packages/web-server/src/config/server-config.service.test.ts deleted file mode 100644 index c2acbbc..0000000 --- a/packages/web-server/src/config/server-config.service.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import assert from 'node:assert'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { parse } from 'yaml'; -import { afterEach, describe, test, vi } from 'vitest'; - -vi.mock('../workspaces/workspaces.utils', () => ({ - defaultWorkspaceName: (cwd: string) => - cwd.split('/').filter(Boolean).at(-1) ?? 'workspace', - normalizeWorkspacePath: (input: string) => input.replace(/\\/g, '/'), - workspaceIdFromCwd: (cwd: string) => - `workspace-${cwd.replace(/[^a-zA-Z0-9]/g, '_')}`, -})); - -import { ServerConfigService } from './server-config.service'; - -type EnvSnapshot = { - memoHome: string | undefined; - serverConfig: string | undefined; -}; - -function snapshotEnv(): EnvSnapshot { - return { - memoHome: process.env.MEMO_HOME, - serverConfig: process.env.MEMO_SERVER_CONFIG, - }; -} - -function restoreEnv(snapshot: EnvSnapshot): void { - if (snapshot.memoHome === undefined) { - delete process.env.MEMO_HOME; - } else { - process.env.MEMO_HOME = snapshot.memoHome; - } - if (snapshot.serverConfig === undefined) { - delete process.env.MEMO_SERVER_CONFIG; - } else { - process.env.MEMO_SERVER_CONFIG = snapshot.serverConfig; - } -} - -const tempRoots: string[] = []; - -afterEach(async () => { - while (tempRoots.length > 0) { - const item = tempRoots.pop(); - if (!item) continue; - await rm(item, { recursive: true, force: true }); - } -}); - -describe('ServerConfigService', () => { - test('preserves configured password scalar when normalizing config', async () => { - const envSnapshot = snapshotEnv(); - const memoHome = await mkdtemp(join(tmpdir(), 'memo-web-server-config-')); - tempRoots.push(memoHome); - process.env.MEMO_HOME = memoHome; - delete process.env.MEMO_SERVER_CONFIG; - - const configPath = join(memoHome, 'server.yaml'); - await writeFile( - configPath, - [ - 'auth:', - ' username: memo', - ' password: 123456', - 'security:', - ' corsAllowedHosts:', - ' - localhost', - '', - ].join('\n'), - 'utf8', - ); - - try { - const first = await new ServerConfigService().load(); - assert.strictEqual(first.auth.password, '123456'); - - const persisted = parse(await readFile(configPath, 'utf8')) as { - auth?: { password?: unknown }; - }; - assert.strictEqual( - typeof persisted.auth?.password, - 'string', - 'password should persist as string after normalization rewrite', - ); - assert.strictEqual(persisted.auth?.password, '123456'); - - const second = await new ServerConfigService().load(); - assert.strictEqual(second.auth.password, '123456'); - } finally { - restoreEnv(envSnapshot); - } - }); - - test('creates default yaml config when missing', async () => { - const envSnapshot = snapshotEnv(); - const memoHome = await mkdtemp(join(tmpdir(), 'memo-web-server-default-')); - tempRoots.push(memoHome); - process.env.MEMO_HOME = memoHome; - delete process.env.MEMO_SERVER_CONFIG; - - const service = new ServerConfigService(); - try { - const loaded = await service.load(); - const configPath = join(memoHome, 'server.yaml'); - const persisted = parse(await readFile(configPath, 'utf8')) as { - auth?: { username?: string; password?: string }; - }; - - assert.strictEqual(service.getConfigPath(), configPath); - assert.strictEqual(loaded.auth.username, 'memo'); - assert.ok(loaded.auth.password.length > 0); - assert.strictEqual(persisted.auth?.username, 'memo'); - assert.strictEqual(typeof persisted.auth?.password, 'string'); - } finally { - restoreEnv(envSnapshot); - } - }); - - test('loads explicit JSON config path and normalizes malformed fields', async () => { - const envSnapshot = snapshotEnv(); - const memoHome = await mkdtemp(join(tmpdir(), 'memo-web-server-json-')); - tempRoots.push(memoHome); - const configPath = join(memoHome, 'custom-server.json'); - - process.env.MEMO_HOME = memoHome; - process.env.MEMO_SERVER_CONFIG = configPath; - - await writeFile( - configPath, - JSON.stringify( - { - auth: { - username: true, - password: 12345, - accessTokenSecret: '', - refreshTokenSecret: '', - accessTokenTtlSeconds: -1, - refreshTokenTtlSeconds: 'abc', - }, - security: { - corsAllowedHosts: [], - }, - workspaces: [ - { id: 'same', name: 'workspace-a', cwd: '/tmp/workspace-a' }, - { id: 'same', name: 'workspace-b', cwd: '/tmp/workspace-a' }, - { foo: 'invalid' }, - ], - workspaceBrowser: { - rootPath: '', - }, - }, - null, - 2, - ), - 'utf8', - ); - - const service = new ServerConfigService(); - try { - const loaded = await service.load(); - assert.strictEqual(loaded.auth.username, 'true'); - assert.strictEqual(loaded.auth.password, '12345'); - assert.strictEqual(loaded.workspaces.length, 1); - assert.strictEqual(loaded.workspaceBrowser.rootPath, '/'); - assert.ok(loaded.security.corsAllowedHosts.length > 0); - - const persisted = JSON.parse(await readFile(configPath, 'utf8')) as { - workspaces?: unknown[]; - auth?: { accessTokenSecret?: string; refreshTokenSecret?: string }; - }; - assert.strictEqual(Array.isArray(persisted.workspaces), true); - assert.strictEqual(persisted.workspaces?.length, 1); - assert.ok((persisted.auth?.accessTokenSecret?.length ?? 0) > 0); - assert.ok((persisted.auth?.refreshTokenSecret?.length ?? 0) > 0); - } finally { - restoreEnv(envSnapshot); - } - }); - - test('throws clear parsing error for invalid json config', async () => { - const envSnapshot = snapshotEnv(); - const memoHome = await mkdtemp(join(tmpdir(), 'memo-web-server-bad-json-')); - tempRoots.push(memoHome); - const configPath = join(memoHome, 'server.json'); - - process.env.MEMO_HOME = memoHome; - process.env.MEMO_SERVER_CONFIG = configPath; - await writeFile(configPath, '{"auth": ', 'utf8'); - - try { - await assert.rejects( - () => new ServerConfigService().load(), - /Failed to parse .*server\.json/, - ); - } finally { - restoreEnv(envSnapshot); - } - }); - - test('updateConfig persists normalized updates', async () => { - const envSnapshot = snapshotEnv(); - const memoHome = await mkdtemp(join(tmpdir(), 'memo-web-server-update-')); - tempRoots.push(memoHome); - process.env.MEMO_HOME = memoHome; - delete process.env.MEMO_SERVER_CONFIG; - - const service = new ServerConfigService(); - try { - await service.load(); - const updated = await service.updateConfig((config) => ({ - ...config, - security: { - corsAllowedHosts: ['example.com', 'localhost'], - }, - workspaceBrowser: { - rootPath: '/tmp/root', - }, - })); - - assert.deepStrictEqual(updated.security.corsAllowedHosts, [ - 'example.com', - 'localhost', - ]); - assert.strictEqual(updated.workspaceBrowser.rootPath, '/tmp/root'); - - const persisted = parse( - await readFile(join(memoHome, 'server.yaml'), 'utf8'), - ) as { - security?: { corsAllowedHosts?: string[] }; - workspaceBrowser?: { rootPath?: string }; - }; - assert.deepStrictEqual(persisted.security?.corsAllowedHosts, [ - 'example.com', - 'localhost', - ]); - assert.strictEqual(persisted.workspaceBrowser?.rootPath, '/tmp/root'); - } finally { - restoreEnv(envSnapshot); - } - }); -}); diff --git a/packages/web-server/src/config/server-config.service.ts b/packages/web-server/src/config/server-config.service.ts deleted file mode 100644 index 3567350..0000000 --- a/packages/web-server/src/config/server-config.service.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, extname, join } from 'node:path'; -import { Injectable, Logger } from '@nestjs/common'; -import { parse, stringify } from 'yaml'; -import type { - ServerRuntimeConfig, - ServerWorkspaceRecord, -} from './server-config.types'; -import { - defaultWorkspaceName, - normalizeWorkspacePath, - workspaceIdFromCwd, -} from '../workspaces/workspaces.utils'; - -const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60; -const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; -const DEFAULT_ALLOWED_CORS_HOSTS = [ - 'localhost', - '127.0.0.1', - '::1', - '*.ngrok-free.app', -]; -const DEFAULT_WORKSPACE_ROOT_PATH = '/'; - -type ParsedServerConfig = { - auth?: { - username?: unknown; - password?: unknown; - accessTokenSecret?: unknown; - refreshTokenSecret?: unknown; - accessTokenTtlSeconds?: unknown; - refreshTokenTtlSeconds?: unknown; - }; - security?: { - corsAllowedHosts?: unknown; - }; - workspaces?: unknown; - workspaceBrowser?: { - rootPath?: unknown; - }; -}; - -function expandHomePath(path: string): string { - if (!path.startsWith('~')) return path; - return join(homedir(), path.slice(1)); -} - -function resolveMemoHome(): string { - const memoHome = process.env.MEMO_HOME; - if (!memoHome) return join(homedir(), '.memo'); - return expandHomePath(memoHome); -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -async function resolveServerConfigPath(): Promise { - const explicitPath = process.env.MEMO_SERVER_CONFIG; - if (explicitPath) { - return expandHomePath(explicitPath); - } - - const memoHome = resolveMemoHome(); - const yamlPath = join(memoHome, 'server.yaml'); - const jsonPath = join(memoHome, 'server.json'); - - if (await pathExists(yamlPath)) return yamlPath; - if (await pathExists(jsonPath)) return jsonPath; - - return yamlPath; -} - -function randomSecret(bytes: number): string { - return randomBytes(bytes).toString('hex'); -} - -function randomPassword(): string { - return randomBytes(12).toString('base64url'); -} - -function readString(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function readAuthString(value: unknown): string | null { - if (typeof value === 'string') { - return readString(value); - } - if ( - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'bigint' - ) { - const normalized = String(value).trim(); - return normalized.length > 0 ? normalized : null; - } - return null; -} - -function normalizePositiveInt(value: unknown, fallback: number): number { - if (typeof value === 'number' && Number.isInteger(value) && value > 0) - return value; - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed) && parsed > 0) return parsed; - } - return fallback; -} - -function normalizeAllowedHosts(input: unknown): string[] { - if (!Array.isArray(input)) return [...DEFAULT_ALLOWED_CORS_HOSTS]; - const hosts = input - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => item.length > 0); - if (hosts.length === 0) return [...DEFAULT_ALLOWED_CORS_HOSTS]; - return Array.from(new Set(hosts)); -} - -function normalizeWorkspaceRootPath(value: unknown): string { - if (typeof value !== 'string' || !value.trim()) { - return DEFAULT_WORKSPACE_ROOT_PATH; - } - return normalizeWorkspacePath(value); -} - -function normalizeWorkspaceRecords(input: unknown): { - items: ServerWorkspaceRecord[]; - changed: boolean; -} { - if (!Array.isArray(input)) { - return { items: [], changed: true }; - } - - const now = new Date().toISOString(); - const byId = new Map(); - let changed = false; - - for (const item of input) { - if (!item || typeof item !== 'object' || Array.isArray(item)) { - changed = true; - continue; - } - const record = item as Record; - const rawCwd = readString(record.cwd); - if (!rawCwd) { - changed = true; - continue; - } - - const cwd = normalizeWorkspacePath(rawCwd); - const id = readString(record.id) ?? workspaceIdFromCwd(cwd); - const name = readString(record.name) ?? defaultWorkspaceName(cwd); - const createdAt = readString(record.createdAt) ?? now; - const lastUsedAt = readString(record.lastUsedAt) ?? createdAt; - if ( - id !== record.id || - name !== record.name || - cwd !== record.cwd || - createdAt !== record.createdAt || - lastUsedAt !== record.lastUsedAt - ) { - changed = true; - } - - byId.set(id, { - id, - name, - cwd, - createdAt, - lastUsedAt, - }); - } - - const items = Array.from(byId.values()).sort((a, b) => - a.cwd.localeCompare(b.cwd), - ); - if (items.length !== input.length) { - changed = true; - } - - return { - items, - changed, - }; -} - -function parseConfigByExtension( - raw: string, - configPath: string, -): ParsedServerConfig { - if (!raw.trim()) return {}; - - const ext = extname(configPath).toLowerCase(); - - if (ext === '.json') { - try { - return JSON.parse(raw) as ParsedServerConfig; - } catch (err) { - throw new Error( - `Failed to parse ${configPath}: ${(err as Error).message}. Please fix the JSON format.`, - ); - } - } - - try { - return (parse(raw) as ParsedServerConfig | null) ?? {}; - } catch (err) { - throw new Error( - `Failed to parse ${configPath}: ${(err as Error).message}. Please fix the YAML format.`, - ); - } -} - -@Injectable() -export class ServerConfigService { - private readonly logger = new Logger(ServerConfigService.name); - private configPath: string | null = null; - private config: ServerRuntimeConfig | null = null; - - async load(): Promise { - if (this.config) return this.config; - - const { configPath, config, generated } = await this.readOrCreateConfig(); - this.configPath = configPath; - this.config = config; - - if (generated) { - this.logger.warn( - [ - `Created web-server auth config: ${configPath}`, - `username="${config.auth.username}"`, - `password="${config.auth.password}"`, - 'Please change the password after first login.', - ].join(' | '), - ); - } - - return config; - } - - getConfigPath(): string { - if (this.configPath) return this.configPath; - const configured = process.env.MEMO_SERVER_CONFIG; - if (configured) return expandHomePath(configured); - return join(resolveMemoHome(), 'server.yaml'); - } - - getLoadedConfig(): ServerRuntimeConfig { - if (!this.config) { - throw new Error('Server config is not loaded yet. Call load() first.'); - } - return this.config; - } - - async updateConfig( - mutator: (config: ServerRuntimeConfig) => ServerRuntimeConfig, - ): Promise { - const current = await this.load(); - const next = mutator(current); - const normalized = this.normalizeParsedConfig( - next as ParsedServerConfig, - ).config; - const configPath = this.getConfigPath(); - await this.writeConfig(configPath, normalized); - this.config = normalized; - return normalized; - } - - private async readOrCreateConfig(): Promise<{ - configPath: string; - config: ServerRuntimeConfig; - generated: boolean; - }> { - const configPath = await resolveServerConfigPath(); - - try { - await access(configPath); - } catch { - const created = this.createDefaultConfig(); - await this.writeConfig(configPath, created); - return { configPath, config: created, generated: true }; - } - - const raw = await readFile(configPath, 'utf8'); - const parsed = parseConfigByExtension(raw, configPath); - - const normalized = this.normalizeParsedConfig(parsed); - if (normalized.rewriteRequired) { - await this.writeConfig(configPath, normalized.config); - } - return { configPath, config: normalized.config, generated: false }; - } - - private createDefaultConfig(): ServerRuntimeConfig { - return { - auth: { - username: 'memo', - password: randomPassword(), - accessTokenSecret: randomSecret(32), - refreshTokenSecret: randomSecret(48), - accessTokenTtlSeconds: DEFAULT_ACCESS_TOKEN_TTL_SECONDS, - refreshTokenTtlSeconds: DEFAULT_REFRESH_TOKEN_TTL_SECONDS, - }, - security: { - corsAllowedHosts: [...DEFAULT_ALLOWED_CORS_HOSTS], - }, - workspaces: [], - workspaceBrowser: { - rootPath: DEFAULT_WORKSPACE_ROOT_PATH, - }, - }; - } - - private normalizeParsedConfig(parsed: ParsedServerConfig): { - config: ServerRuntimeConfig; - rewriteRequired: boolean; - } { - let rewriteRequired = false; - - const username = readAuthString(parsed.auth?.username) ?? 'memo'; - if (!readAuthString(parsed.auth?.username)) rewriteRequired = true; - - // Existing config should not generate a new random password on each start. - // YAML may parse unquoted scalars (for example: password: 123456) as numbers. - const password = readAuthString(parsed.auth?.password) ?? 'memo'; - if (!readAuthString(parsed.auth?.password)) rewriteRequired = true; - - const accessTokenSecret = - readAuthString(parsed.auth?.accessTokenSecret) ?? randomSecret(32); - if (!readAuthString(parsed.auth?.accessTokenSecret)) rewriteRequired = true; - - const refreshTokenSecret = - readAuthString(parsed.auth?.refreshTokenSecret) ?? randomSecret(48); - if (!readAuthString(parsed.auth?.refreshTokenSecret)) - rewriteRequired = true; - - const accessTokenTtlSeconds = normalizePositiveInt( - parsed.auth?.accessTokenTtlSeconds, - DEFAULT_ACCESS_TOKEN_TTL_SECONDS, - ); - if (accessTokenTtlSeconds !== parsed.auth?.accessTokenTtlSeconds) - rewriteRequired = true; - - const refreshTokenTtlSeconds = normalizePositiveInt( - parsed.auth?.refreshTokenTtlSeconds, - DEFAULT_REFRESH_TOKEN_TTL_SECONDS, - ); - if (refreshTokenTtlSeconds !== parsed.auth?.refreshTokenTtlSeconds) - rewriteRequired = true; - - const corsAllowedHosts = normalizeAllowedHosts( - parsed.security?.corsAllowedHosts, - ); - if ( - !Array.isArray(parsed.security?.corsAllowedHosts) || - corsAllowedHosts.length !== parsed.security.corsAllowedHosts.length - ) { - rewriteRequired = true; - } - - const normalizedWorkspaces = normalizeWorkspaceRecords(parsed.workspaces); - if (normalizedWorkspaces.changed) { - rewriteRequired = true; - } - - const workspaceRootPath = normalizeWorkspaceRootPath( - parsed.workspaceBrowser?.rootPath, - ); - if (workspaceRootPath !== parsed.workspaceBrowser?.rootPath) { - rewriteRequired = true; - } - - return { - config: { - auth: { - username, - password, - accessTokenSecret, - refreshTokenSecret, - accessTokenTtlSeconds, - refreshTokenTtlSeconds, - }, - security: { - corsAllowedHosts, - }, - workspaces: normalizedWorkspaces.items, - workspaceBrowser: { - rootPath: workspaceRootPath, - }, - }, - rewriteRequired, - }; - } - - private async writeConfig( - configPath: string, - config: ServerRuntimeConfig, - ): Promise { - await mkdir(dirname(configPath), { recursive: true }); - - const ext = extname(configPath).toLowerCase(); - const content = - ext === '.json' - ? `${JSON.stringify(config, null, 2)}\n` - : stringify(config); - - await writeFile(configPath, content, { - encoding: 'utf8', - mode: 0o600, - }); - } -} diff --git a/packages/web-server/src/config/server-config.types.ts b/packages/web-server/src/config/server-config.types.ts deleted file mode 100644 index b89a492..0000000 --- a/packages/web-server/src/config/server-config.types.ts +++ /dev/null @@ -1,31 +0,0 @@ -export type ServerAuthConfig = { - username: string; - password: string; - accessTokenSecret: string; - refreshTokenSecret: string; - accessTokenTtlSeconds: number; - refreshTokenTtlSeconds: number; -}; - -export type ServerSecurityConfig = { - corsAllowedHosts: string[]; -}; - -export type ServerWorkspaceRecord = { - id: string; - name: string; - cwd: string; - createdAt: string; - lastUsedAt: string; -}; - -export type ServerWorkspaceBrowserConfig = { - rootPath: string; -}; - -export type ServerRuntimeConfig = { - auth: ServerAuthConfig; - security: ServerSecurityConfig; - workspaces: ServerWorkspaceRecord[]; - workspaceBrowser: ServerWorkspaceBrowserConfig; -}; diff --git a/packages/web-server/src/main.ts b/packages/web-server/src/main.ts deleted file mode 100644 index 16b75fd..0000000 --- a/packages/web-server/src/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { startMemoWebServer } from './server'; - -function parsePort(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number.parseInt(value, 10); - if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) return parsed; - return undefined; -} - -async function bootstrap() { - await startMemoWebServer({ - host: process.env.MEMO_WEB_HOST ?? process.env.HOST, - port: parsePort(process.env.MEMO_WEB_PORT ?? process.env.PORT), - staticDir: process.env.MEMO_WEB_STATIC_DIR, - }); -} - -void bootstrap(); diff --git a/packages/web-server/src/mcp/mcp.controller.ts b/packages/web-server/src/mcp/mcp.controller.ts deleted file mode 100644 index 6aab0d7..0000000 --- a/packages/web-server/src/mcp/mcp.controller.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - BadRequestException, - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, -} from '@nestjs/common'; -import { McpService } from './mcp.service'; - -type UpsertMcpBody = { - name?: unknown; - config?: unknown; -}; - -type LoginBody = { - scopes?: unknown; -}; - -type ActiveBody = { - names?: unknown; -}; - -function requiredName(value: unknown): string { - if (typeof value !== 'string' || !value.trim()) { - throw new BadRequestException('name is required'); - } - return value.trim(); -} - -@Controller('api/mcp') -export class McpController { - constructor(private readonly mcpService: McpService) {} - - @Get('servers') - async list() { - return this.mcpService.list(); - } - - @Get('servers/:name') - async get(@Param('name') name: string) { - return this.mcpService.get(name); - } - - @Post('servers') - async create(@Body() body: UpsertMcpBody) { - const name = requiredName(body.name); - return this.mcpService.create(name, body.config); - } - - @Put('servers/:name') - async update(@Param('name') name: string, @Body() body: UpsertMcpBody) { - return this.mcpService.update(name, body.config); - } - - @Delete('servers/:name') - async remove(@Param('name') name: string) { - return this.mcpService.remove(name); - } - - @Post('servers/:name/login') - async login(@Param('name') name: string, @Body() body: LoginBody) { - const scopes = Array.isArray(body.scopes) - ? body.scopes.filter((item): item is string => typeof item === 'string') - : undefined; - return this.mcpService.login(name, scopes); - } - - @Post('servers/:name/logout') - async logout(@Param('name') name: string) { - return this.mcpService.logout(name); - } - - @Put('active') - async setActive(@Body() body: ActiveBody) { - if (!Array.isArray(body.names)) { - throw new BadRequestException('names must be string[]'); - } - const names = body.names - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean); - return this.mcpService.setActive(names); - } -} diff --git a/packages/web-server/src/mcp/mcp.module.ts b/packages/web-server/src/mcp/mcp.module.ts deleted file mode 100644 index e74cf2f..0000000 --- a/packages/web-server/src/mcp/mcp.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { McpController } from './mcp.controller'; -import { McpService } from './mcp.service'; - -@Module({ - controllers: [McpController], - providers: [McpService], - exports: [McpService], -}) -export class McpModule {} diff --git a/packages/web-server/src/mcp/mcp.service.ts b/packages/web-server/src/mcp/mcp.service.ts deleted file mode 100644 index d563185..0000000 --- a/packages/web-server/src/mcp/mcp.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - BadRequestException, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { - createMcpServer, - getMcpServer, - listMcpServers, - loginMcpServer, - logoutMcpServer, - McpAdminError, - removeMcpServer, - setActiveMcpServers, - updateMcpServer, -} from '@memo-code/core'; - -@Injectable() -export class McpService { - async list() { - return this.wrap(() => listMcpServers()); - } - - async get(name: string) { - return this.wrap(() => getMcpServer(name)); - } - - async create(name: string, configInput: unknown) { - return this.wrap(() => createMcpServer(name, configInput)); - } - - async update(name: string, configInput: unknown) { - return this.wrap(() => updateMcpServer(name, configInput)); - } - - async remove(name: string) { - return this.wrap(() => removeMcpServer(name)); - } - - async login(name: string, scopes: string[] | undefined) { - return this.wrap(() => loginMcpServer(name, scopes)); - } - - async logout(name: string) { - return this.wrap(() => logoutMcpServer(name)); - } - - async setActive(names: string[]) { - return this.wrap(() => setActiveMcpServers(names)); - } - - private async wrap(fn: () => Promise): Promise { - try { - return await fn(); - } catch (error) { - if (error instanceof McpAdminError) { - if (error.code === 'NOT_FOUND') { - throw new NotFoundException(error.message); - } - throw new BadRequestException(error.message); - } - - const message = error instanceof Error ? error.message : String(error); - throw new InternalServerErrorException(message || 'mcp operation failed'); - } - } -} diff --git a/packages/web-server/src/server.ts b/packages/web-server/src/server.ts deleted file mode 100644 index ed1aa74..0000000 --- a/packages/web-server/src/server.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { Logger } from '@nestjs/common'; -import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; -import { NestFactory } from '@nestjs/core'; -import type { NestExpressApplication } from '@nestjs/platform-express'; -import type { Request, Response } from 'express'; -import { AppModule } from './app.module'; -import { AuthService } from './auth/auth.service'; -import { ServerConfigService } from './config/server-config.service'; -import { StreamService } from './stream/stream.service'; -import { WsGatewayService } from './ws/ws-gateway.service'; - -const DEFAULT_HOST = '127.0.0.1'; -const DEFAULT_PORT = 5494; -const BUILTIN_ALLOWED_CORS_HOSTS = ['*.ngrok-free.app']; - -export type StartMemoWebServerOptions = { - host?: string; - port?: number; - staticDir?: string; -}; - -export type StartedMemoWebServer = { - app: NestExpressApplication; - host: string; - port: number; - staticDir: string | null; - configPath: string; - url: string; - close: () => Promise; -}; - -function parsePort(value: number | undefined, fallback: number): number { - if (typeof value !== 'number') return fallback; - if (Number.isInteger(value) && value > 0 && value <= 65535) return value; - return fallback; -} - -function allowCorsOrigin( - origin: string | undefined, - allowedHosts: string[], -): boolean { - if (!origin) return true; - try { - const url = new URL(origin); - const hostname = url.hostname.trim().toLowerCase(); - if (!hostname) return false; - - const normalizedAllowedHosts = allowedHosts - .map((item) => item.trim().toLowerCase()) - .filter((item) => item.length > 0); - - return normalizedAllowedHosts.some((pattern) => { - if (pattern === '*') return true; - if (pattern === hostname) return true; - - if (pattern.startsWith('*.')) { - const suffix = pattern.slice(1); - return hostname.endsWith(suffix); - } - - if (pattern.startsWith('.')) { - return hostname === pattern.slice(1) || hostname.endsWith(pattern); - } - - return false; - }); - } catch { - return false; - } -} - -function normalizeHost(value: string | undefined): string { - if (!value) return DEFAULT_HOST; - const trimmed = value.trim(); - return trimmed || DEFAULT_HOST; -} - -function resolveStaticDir(staticDir: string | undefined): string | null { - if (!staticDir) return null; - const resolved = resolve(staticDir); - const indexPath = join(resolved, 'index.html'); - if (!existsSync(indexPath)) return null; - return resolved; -} - -function setupStaticHosting( - app: NestExpressApplication, - staticDir: string | null, - logger: Logger, -): void { - if (!staticDir) { - logger.warn( - 'web-ui static directory is not configured or missing index.html', - ); - return; - } - - app.useStaticAssets(staticDir, { index: false }); - const expressApp = app.getHttpAdapter().getInstance() as { - get: ( - path: string | RegExp, - handler: (req: Request, res: Response) => void, - ) => void; - }; - - expressApp.get(/^\/(?!api(?:\/|$)|healthz$).*/, (_req, res) => { - // Use root + relative file path to avoid dotfile filtering on absolute paths - // (e.g. global installs under "~/.nvm/..."). - res.sendFile('index.html', { root: staticDir }); - }); - logger.log(`Serving web-ui static files from ${staticDir}`); -} - -export async function startMemoWebServer( - options: StartMemoWebServerOptions = {}, -): Promise { - const logger = new Logger('WebServer'); - const app = await NestFactory.create(AppModule); - const configService = app.get(ServerConfigService); - const config = await configService.load(); - const effectiveAllowedCorsHosts = Array.from( - new Set([ - ...config.security.corsAllowedHosts, - ...BUILTIN_ALLOWED_CORS_HOSTS, - ]), - ); - - const corsOptions: CorsOptions = { - origin: ( - origin: string | undefined, - callback: (error: Error | null, allow?: boolean) => void, - ) => { - if (allowCorsOrigin(origin, effectiveAllowedCorsHosts)) { - callback(null, true); - return; - } - callback(new Error('CORS origin is not allowed')); - }, - credentials: true, - methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'x-request-id'], - }; - app.enableCors(corsOptions); - - const host = normalizeHost(options.host); - const port = parsePort(options.port, DEFAULT_PORT); - const staticDir = resolveStaticDir(options.staticDir); - setupStaticHosting(app, staticDir, logger); - const streamService = app.get(StreamService); - const wsGatewayService = app.get(WsGatewayService); - const authService = app.get(AuthService); - streamService.attach({ - httpServer: app.getHttpServer(), - verifyAccessToken: (token) => authService.verifyAccessToken(token), - }); - wsGatewayService.attach({ - httpServer: app.getHttpServer(), - verifyAccessToken: (token) => authService.verifyAccessToken(token), - }); - - await app.listen(port, host); - const url = `http://${host}:${port}`; - logger.log(`Memo web-server started at ${url}`); - logger.log(`Using auth config: ${configService.getConfigPath()}`); - logger.log(`Allowed CORS hosts: ${effectiveAllowedCorsHosts.join(', ')}`); - - return { - app, - host, - port, - staticDir, - configPath: configService.getConfigPath(), - url, - close: async () => { - await app.close(); - }, - }; -} - -export function defaultMemoWebHost(): string { - return DEFAULT_HOST; -} - -export function defaultMemoWebPort(): number { - return DEFAULT_PORT; -} diff --git a/packages/web-server/src/sessions/sessions.controller.ts b/packages/web-server/src/sessions/sessions.controller.ts deleted file mode 100644 index d19cc22..0000000 --- a/packages/web-server/src/sessions/sessions.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { SessionsService } from './sessions.service'; - -@Controller('api/sessions') -export class SessionsController { - constructor(private readonly sessionsService: SessionsService) {} - - @Get() - async listSessions(@Query() query: Record) { - return this.sessionsService.list(query); - } - - @Get(':id') - async getSession(@Param('id') sessionId: string) { - return this.sessionsService.getSessionDetail(sessionId); - } - - @Get(':id/events') - async getSessionEvents( - @Param('id') sessionId: string, - @Query() query: Record, - ) { - return this.sessionsService.getSessionEvents(sessionId, query); - } -} diff --git a/packages/web-server/src/sessions/sessions.module.ts b/packages/web-server/src/sessions/sessions.module.ts deleted file mode 100644 index 70d3a19..0000000 --- a/packages/web-server/src/sessions/sessions.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { WorkspacesModule } from '../workspaces/workspaces.module'; -import { SessionsController } from './sessions.controller'; -import { SessionsService } from './sessions.service'; - -@Module({ - imports: [WorkspacesModule], - controllers: [SessionsController], - providers: [SessionsService], - exports: [SessionsService], -}) -export class SessionsModule {} diff --git a/packages/web-server/src/sessions/sessions.service.ts b/packages/web-server/src/sessions/sessions.service.ts deleted file mode 100644 index 3e96681..0000000 --- a/packages/web-server/src/sessions/sessions.service.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { lstat, rmdir, unlink } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join, resolve, sep } from 'node:path'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { - HistoryIndex, - normalizeWorkspacePath, - workspaceIdFromCwd, -} from '@memo-code/core'; -import { WorkspacesService } from '../workspaces/workspaces.service'; -import type { - ListSessionsQuery, - SessionDetail, - SessionEventsQuery, - SessionEventsResponse, - SessionListResponse, -} from './sessions.types'; - -function resolveMemoHome(): string { - const memoHome = process.env.MEMO_HOME; - if (!memoHome || !memoHome.trim()) return join(homedir(), '.memo'); - if (!memoHome.startsWith('~')) return memoHome; - return join(homedir(), memoHome.slice(1)); -} - -function parsePage(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - const normalized = Math.floor(value); - return normalized > 0 ? normalized : undefined; - } - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; - } - return undefined; -} - -function parseSortBy(value: unknown): ListSessionsQuery['sortBy'] | undefined { - if (typeof value !== 'string') return undefined; - if ( - value === 'updatedAt' || - value === 'startedAt' || - value === 'project' || - value === 'title' - ) { - return value; - } - return undefined; -} - -function parseOrder(value: unknown): ListSessionsQuery['order'] | undefined { - if (value === 'asc' || value === 'desc') return value; - return undefined; -} - -function parseString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -@Injectable() -export class SessionsService { - private readonly sessionsDir = resolve(join(resolveMemoHome(), 'sessions')); - private readonly historyIndex = new HistoryIndex({ - sessionsDir: this.sessionsDir, - }); - - constructor(private readonly workspacesService: WorkspacesService) {} - - async list(query: Record): Promise { - const normalized: ListSessionsQuery = { - page: parsePage(query.page), - pageSize: parsePage(query.pageSize), - sortBy: parseSortBy(query.sortBy), - order: parseOrder(query.order), - project: parseString(query.project), - workspaceId: parseString(query.workspaceId), - dateFrom: parseString(query.dateFrom), - dateTo: parseString(query.dateTo), - q: parseString(query.q), - }; - - if (normalized.workspaceId) { - const workspace = await this.workspacesService.getById( - normalized.workspaceId, - ); - if (!workspace) { - return { - items: [], - page: normalized.page ?? 1, - pageSize: normalized.pageSize ?? 20, - total: 0, - totalPages: 0, - }; - } - normalized.workspaceCwd = workspace.cwd; - } - - return this.historyIndex.list(normalized); - } - - async getSessionDetail(sessionId: string): Promise { - const detail = await this.historyIndex.getSessionDetail(sessionId); - if (!detail) { - throw new NotFoundException('Session not found'); - } - return { - ...detail, - workspaceId: detail.workspaceId || workspaceIdFromCwd(detail.cwd), - }; - } - - async getSessionEvents( - sessionId: string, - query: Record, - ): Promise { - const normalized: SessionEventsQuery = { - cursor: parseString(query.cursor), - limit: parsePage(query.limit), - }; - const events = await this.historyIndex.getSessionEvents( - sessionId, - normalized.cursor, - normalized.limit, - ); - if (!events) { - throw new NotFoundException('Session not found'); - } - return events; - } - - async listAllSessionSummaries() { - return this.historyIndex.getAllSummaries(); - } - - async removeSession(sessionId: string): Promise<{ deleted: boolean }> { - const target = sessionId.trim(); - if (!target) { - throw new NotFoundException('Session not found'); - } - - const detail = await this.historyIndex.getSessionDetail(target); - if (!detail) { - throw new NotFoundException('Session not found'); - } - - await this.removeSessionFile(detail.filePath); - - await this.historyIndex.refresh(); - return { deleted: true }; - } - - async removeSessionsByWorkspace( - workspaceId: string, - ): Promise<{ deleted: boolean; deletedSessions: number }> { - const target = workspaceId.trim(); - if (!target) { - throw new NotFoundException('workspace not found'); - } - - const workspace = await this.workspacesService.getById(target); - if (!workspace) { - throw new NotFoundException('workspace not found'); - } - - const workspaceCwd = normalizeWorkspacePath(workspace.cwd); - const summaries = await this.historyIndex.getAllSummaries(); - const filePaths: string[] = Array.from( - new Set( - summaries - .filter( - (summary) => normalizeWorkspacePath(summary.cwd) === workspaceCwd, - ) - .map((summary) => summary.filePath) - .filter( - (filePath): filePath is string => - typeof filePath === 'string' && filePath.trim().length > 0, - ), - ), - ); - - let deletedSessions = 0; - for (const filePath of filePaths) { - const deleted = await this.removeSessionFile(filePath); - if (deleted) { - deletedSessions += 1; - } - } - - await this.historyIndex.refresh(); - return { deleted: true, deletedSessions }; - } - - private normalizeSafeSessionFilePath(filePath: string): string { - const resolvedPath = resolve(filePath); - if (!resolvedPath.endsWith('.jsonl')) { - throw new Error( - `Refusing to remove non-jsonl session file: ${resolvedPath}`, - ); - } - if ( - resolvedPath === this.sessionsDir || - !resolvedPath.startsWith(`${this.sessionsDir}${sep}`) - ) { - throw new Error( - `Refusing to remove file outside sessions dir: ${resolvedPath}`, - ); - } - return resolvedPath; - } - - private async removeSessionFile(filePath: string): Promise { - const safePath = this.normalizeSafeSessionFilePath(filePath); - - try { - const info = await lstat(safePath); - if (!info.isFile() || info.isSymbolicLink()) { - throw new Error(`Refusing to remove non-regular file: ${safePath}`); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException)?.code; - if (code === 'ENOENT') return false; - throw error; - } - - try { - await unlink(safePath); - } catch (error) { - const code = (error as NodeJS.ErrnoException)?.code; - if (code === 'ENOENT') return false; - throw error; - } - - await this.pruneEmptyParentDirectories(safePath); - return true; - } - - private async pruneEmptyParentDirectories(filePath: string): Promise { - let current = dirname(filePath); - while ( - current !== this.sessionsDir && - current.startsWith(`${this.sessionsDir}${sep}`) - ) { - try { - await rmdir(current); - } catch (error) { - const code = (error as NodeJS.ErrnoException)?.code; - if (code === 'ENOENT' || code === 'ENOTEMPTY') { - break; - } - throw error; - } - current = dirname(current); - } - } -} diff --git a/packages/web-server/src/sessions/sessions.types.ts b/packages/web-server/src/sessions/sessions.types.ts deleted file mode 100644 index 9e54b32..0000000 --- a/packages/web-server/src/sessions/sessions.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - SessionDetail, - SessionEventsResponse, - SessionListResponse, -} from '@memo-code/core'; - -export type ListSessionsQuery = { - page?: number; - pageSize?: number; - sortBy?: 'updatedAt' | 'startedAt' | 'project' | 'title'; - order?: 'asc' | 'desc'; - project?: string; - workspaceId?: string; - workspaceCwd?: string; - dateFrom?: string; - dateTo?: string; - q?: string; -}; - -export type SessionEventsQuery = { - cursor?: string; - limit?: number; -}; - -export type { SessionDetail, SessionEventsResponse, SessionListResponse }; diff --git a/packages/web-server/src/skills/skills.controller.ts b/packages/web-server/src/skills/skills.controller.ts deleted file mode 100644 index f2d2964..0000000 --- a/packages/web-server/src/skills/skills.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, - Query, -} from '@nestjs/common'; -import { SkillsService } from './skills.service'; - -type CreateSkillBody = { - scope?: unknown; - workspaceId?: unknown; - name?: unknown; - description?: unknown; - content?: unknown; -}; - -type UpdateSkillBody = { - description?: unknown; - content?: unknown; -}; - -@Controller('api/skills') -export class SkillsController { - constructor(private readonly skillsService: SkillsService) {} - - @Get() - async list(@Query() query: Record) { - return this.skillsService.list({ - scope: query.scope, - q: query.q, - workspaceId: query.workspaceId, - }); - } - - @Get(':id') - async get(@Param('id') id: string) { - return this.skillsService.get(id); - } - - @Post() - async create(@Body() body: CreateSkillBody) { - return this.skillsService.create({ - ...body, - workspaceId: (body as Record).workspaceId, - }); - } - - @Put(':id') - async update(@Param('id') id: string, @Body() body: UpdateSkillBody) { - return this.skillsService.update(id, body); - } - - @Delete(':id') - async remove(@Param('id') id: string) { - return this.skillsService.remove(id); - } -} diff --git a/packages/web-server/src/skills/skills.module.ts b/packages/web-server/src/skills/skills.module.ts deleted file mode 100644 index 252da44..0000000 --- a/packages/web-server/src/skills/skills.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { WorkspacesModule } from '../workspaces/workspaces.module'; -import { SkillsController } from './skills.controller'; -import { SkillsService } from './skills.service'; - -@Module({ - imports: [WorkspacesModule], - controllers: [SkillsController], - providers: [SkillsService], - exports: [SkillsService], -}) -export class SkillsModule {} diff --git a/packages/web-server/src/skills/skills.service.ts b/packages/web-server/src/skills/skills.service.ts deleted file mode 100644 index 796c443..0000000 --- a/packages/web-server/src/skills/skills.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - BadRequestException, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { - createSkill, - getSkill, - listSkills, - removeSkill, - setActiveSkills, - SkillsAdminError, - updateSkill, -} from '@memo-code/core'; -import { WorkspacesService } from '../workspaces/workspaces.service'; - -@Injectable() -export class SkillsService { - constructor(private readonly workspacesService: WorkspacesService) {} - - async list(options: { scope?: unknown; q?: unknown; workspaceId?: unknown }) { - const workspaceCwd = await this.resolveWorkspaceCwd(options.workspaceId, { - requiredWhenProjectScope: options.scope === 'project', - }); - - return this.wrap(() => - listSkills({ - scope: options.scope, - q: options.q, - workspaceCwd, - }), - ); - } - - async get(id: string) { - const workspaceCwds = await this.listWorkspaceCwds(); - return this.wrap(() => getSkill(id, { workspaceCwds })); - } - - async create(input: { - scope?: unknown; - name?: unknown; - description?: unknown; - content?: unknown; - workspaceId?: unknown; - }) { - const workspaceCwd = await this.resolveWorkspaceCwd(input.workspaceId, { - requiredWhenProjectScope: input.scope === 'project', - }); - - return this.wrap(() => - createSkill({ - scope: input.scope, - name: input.name, - description: input.description, - content: input.content, - workspaceCwd, - }), - ); - } - - async update( - id: string, - input: { - description?: unknown; - content?: unknown; - }, - ) { - const workspaceCwds = await this.listWorkspaceCwds(); - return this.wrap(() => updateSkill(id, input, { workspaceCwds })); - } - - async remove(id: string) { - const workspaceCwds = await this.listWorkspaceCwds(); - return this.wrap(() => removeSkill(id, { workspaceCwds })); - } - - async setActive(ids: string[]) { - const workspaceCwds = await this.listWorkspaceCwds(); - return this.wrap(() => setActiveSkills(ids, { workspaceCwds })); - } - - private async listWorkspaceCwds(): Promise { - const workspaces = await this.workspacesService.list(); - return workspaces.items.map((item) => item.cwd); - } - - private async resolveWorkspaceCwd( - workspaceId: unknown, - options: { requiredWhenProjectScope: boolean }, - ): Promise { - const id = typeof workspaceId === 'string' ? workspaceId.trim() : ''; - if (!id) { - if (options.requiredWhenProjectScope) { - throw new BadRequestException( - 'workspaceId is required when scope=project', - ); - } - return null; - } - - const workspace = await this.workspacesService.getById(id); - if (!workspace) { - throw new NotFoundException('workspace not found'); - } - return workspace.cwd; - } - - private async wrap(fn: () => Promise): Promise { - try { - return await fn(); - } catch (error) { - if (error instanceof SkillsAdminError) { - if (error.code === 'NOT_FOUND') { - throw new NotFoundException(error.message); - } - throw new BadRequestException(error.message); - } - - const message = error instanceof Error ? error.message : String(error); - throw new InternalServerErrorException( - message || 'skills operation failed', - ); - } - } -} diff --git a/packages/web-server/src/stream/stream.module.ts b/packages/web-server/src/stream/stream.module.ts deleted file mode 100644 index b16f27c..0000000 --- a/packages/web-server/src/stream/stream.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { StreamService } from './stream.service'; - -@Module({ - providers: [StreamService], - exports: [StreamService], -}) -export class StreamModule {} diff --git a/packages/web-server/src/stream/stream.service.ts b/packages/web-server/src/stream/stream.service.ts deleted file mode 100644 index 01c9e8e..0000000 --- a/packages/web-server/src/stream/stream.service.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { IncomingMessage } from 'node:http'; -import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { WebSocketServer, type WebSocket } from 'ws'; -import type { AccessTokenPayload } from '../auth/auth.types'; - -type AccessTokenVerifier = (token: string) => Promise; - -type AttachOptions = { - httpServer: { - on: ( - event: 'upgrade', - listener: ( - request: IncomingMessage, - socket: import('node:net').Socket, - head: Buffer, - ) => void, - ) => void; - }; - verifyAccessToken: AccessTokenVerifier; -}; - -function matchSessionStreamPath(pathname: string): string | null { - const match = pathname.match(/^\/api\/chat\/sessions\/([^/]+)\/stream$/); - if (!match) return null; - const sessionId = match[1]?.trim(); - return sessionId && sessionId.length > 0 - ? decodeURIComponent(sessionId) - : null; -} - -function readBearerTokenFromHeader( - authorization: string | string[] | undefined, -): string | null { - if (!authorization) return null; - const raw = Array.isArray(authorization) ? authorization[0] : authorization; - if (!raw) return null; - const [scheme, token] = raw.split(' '); - if (scheme !== 'Bearer' || !token) return null; - const trimmed = token.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -@Injectable() -export class StreamService implements OnModuleDestroy { - private readonly logger = new Logger(StreamService.name); - private readonly socketsBySession = new Map>(); - private readonly listenersBySession = new Map< - string, - Set<(payload: unknown) => void> - >(); - private readonly globalListeners = new Set< - (sessionId: string, payload: unknown) => void - >(); - private wsServer: WebSocketServer | null = null; - private attached = false; - - attach(options: AttachOptions): void { - if (this.attached) return; - this.attached = true; - - this.wsServer = new WebSocketServer({ noServer: true }); - - options.httpServer.on('upgrade', (request, socket, head) => { - const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); - const sessionId = matchSessionStreamPath(requestUrl.pathname); - if (!sessionId || !this.wsServer) return; - - const queryToken = - requestUrl.searchParams.get('accessToken')?.trim() ?? null; - const headerToken = readBearerTokenFromHeader( - request.headers.authorization, - ); - const accessToken = headerToken ?? queryToken; - - if (!accessToken) { - socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n'); - socket.destroy(); - return; - } - - void options - .verifyAccessToken(accessToken) - .then(() => { - this.wsServer?.handleUpgrade(request, socket, head, (ws) => { - this.registerSocket(sessionId, ws); - }); - }) - .catch(() => { - socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n'); - socket.destroy(); - }); - }); - } - - broadcast(sessionId: string, payload: unknown): void { - if (this.globalListeners.size > 0) { - for (const listener of this.globalListeners) { - listener(sessionId, payload); - } - } - - const listeners = this.listenersBySession.get(sessionId); - if (listeners && listeners.size > 0) { - for (const listener of listeners) { - listener(payload); - } - } - - const sockets = this.socketsBySession.get(sessionId); - if (!sockets || sockets.size === 0) return; - - const message = JSON.stringify(payload); - for (const socket of sockets) { - if (socket.readyState === socket.OPEN) { - socket.send(message); - } - } - } - - disconnectSession(sessionId: string): void { - const sockets = this.socketsBySession.get(sessionId); - if (!sockets) return; - for (const socket of sockets) { - socket.close(1000, 'session closed'); - } - this.socketsBySession.delete(sessionId); - } - - subscribe( - sessionId: string, - listener: (payload: unknown) => void, - ): () => void { - let listeners = this.listenersBySession.get(sessionId); - if (!listeners) { - listeners = new Set<(payload: unknown) => void>(); - this.listenersBySession.set(sessionId, listeners); - } - listeners.add(listener); - - return () => { - const target = this.listenersBySession.get(sessionId); - if (!target) return; - target.delete(listener); - if (target.size === 0) { - this.listenersBySession.delete(sessionId); - } - }; - } - - subscribeAll( - listener: (sessionId: string, payload: unknown) => void, - ): () => void { - this.globalListeners.add(listener); - return () => { - this.globalListeners.delete(listener); - }; - } - - async onModuleDestroy(): Promise { - if (this.wsServer) { - for (const [, sockets] of this.socketsBySession) { - for (const socket of sockets) { - socket.close(1001, 'server shutdown'); - } - } - this.socketsBySession.clear(); - this.listenersBySession.clear(); - this.globalListeners.clear(); - await new Promise((resolve) => { - this.wsServer?.close(() => resolve()); - }); - this.wsServer = null; - } - } - - private registerSocket(sessionId: string, socket: WebSocket): void { - let sockets = this.socketsBySession.get(sessionId); - if (!sockets) { - sockets = new Set(); - this.socketsBySession.set(sessionId, sockets); - } - sockets.add(socket); - - socket.on('close', () => { - const target = this.socketsBySession.get(sessionId); - if (!target) return; - target.delete(socket); - if (target.size === 0) { - this.socketsBySession.delete(sessionId); - } - }); - - socket.on('error', (error) => { - this.logger.warn( - `socket error for session=${sessionId}: ${error.message}`, - ); - }); - } -} diff --git a/packages/web-server/src/workspaces/workspaces.module.ts b/packages/web-server/src/workspaces/workspaces.module.ts deleted file mode 100644 index 878ccec..0000000 --- a/packages/web-server/src/workspaces/workspaces.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { WorkspacesService } from './workspaces.service'; - -@Module({ - providers: [WorkspacesService], - exports: [WorkspacesService], -}) -export class WorkspacesModule {} diff --git a/packages/web-server/src/workspaces/workspaces.service.ts b/packages/web-server/src/workspaces/workspaces.service.ts deleted file mode 100644 index d02fbb3..0000000 --- a/packages/web-server/src/workspaces/workspaces.service.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { constants } from 'node:fs'; -import { access, readdir, readFile, realpath, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { basename, dirname, join, resolve } from 'node:path'; -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { ServerConfigService } from '../config/server-config.service'; -import type { ServerWorkspaceRecord } from '../config/server-config.types'; -import type { - WorkspaceDirEntry, - WorkspaceFsListResult, - WorkspaceRecord, -} from './workspaces.types'; -import { - defaultWorkspaceName, - normalizeWorkspaceName, - normalizeWorkspacePath, - workspaceIdFromCwd, -} from './workspaces.utils'; - -const MAX_DIRECTORY_ITEMS = 200; - -function resolveMemoHome(): string { - const memoHome = process.env.MEMO_HOME; - if (!memoHome || !memoHome.trim()) return join(homedir(), '.memo'); - if (!memoHome.startsWith('~')) return memoHome; - return join(homedir(), memoHome.slice(1)); -} - -function toWorkspaceRecord(input: ServerWorkspaceRecord): WorkspaceRecord { - return { - id: input.id, - name: input.name, - cwd: input.cwd, - createdAt: input.createdAt, - lastUsedAt: input.lastUsedAt, - }; -} - -function byNameThenPath(a: WorkspaceRecord, b: WorkspaceRecord): number { - const nameResult = a.name.localeCompare(b.name, undefined, { - sensitivity: 'base', - }); - if (nameResult !== 0) return nameResult; - return a.cwd.localeCompare(b.cwd); -} - -function extractCwdFromHistoryLog(raw: string): string | null { - const lines = raw.split('\n'); - for (const line of lines) { - if (!line.trim()) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - continue; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) - continue; - const event = parsed as Record; - if (event.type !== 'session_start') continue; - const meta = event.meta; - if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue; - const cwd = (meta as Record).cwd; - if (typeof cwd !== 'string' || !cwd.trim()) continue; - return normalizeWorkspacePath(cwd); - } - return null; -} - -async function collectHistoryCwds(): Promise { - const sessionsDir = join(resolveMemoHome(), 'sessions'); - const results = new Set(); - - const walk = async (dirPath: string): Promise => { - let entries: import('node:fs').Dirent[]; - try { - entries = await readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (entry.isSymbolicLink()) continue; - const fullPath = join(dirPath, entry.name); - if (entry.isDirectory()) { - await walk(fullPath); - continue; - } - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - - try { - const raw = await readFile(fullPath, 'utf8'); - const cwd = extractCwdFromHistoryLog(raw); - if (cwd) { - results.add(cwd); - } - } catch { - // Ignore invalid history files. - } - } - }; - - await walk(resolve(sessionsDir)); - return Array.from(results.values()).sort((a, b) => a.localeCompare(b)); -} - -function isWithinRoot(path: string, rootPath: string): boolean { - const normalizedPath = normalizeWorkspacePath(path); - const normalizedRoot = normalizeWorkspacePath(rootPath); - if (normalizedRoot === '/') return true; - if (normalizedPath === normalizedRoot) return true; - return normalizedPath.startsWith(`${normalizedRoot}/`); -} - -@Injectable() -export class WorkspacesService { - private historyHydrated = false; - - constructor(private readonly serverConfigService: ServerConfigService) {} - - async list(): Promise<{ items: WorkspaceRecord[] }> { - await this.hydrateFromHistoryIfNeeded(); - const config = await this.serverConfigService.load(); - return { - items: config.workspaces.map(toWorkspaceRecord).sort(byNameThenPath), - }; - } - - async getById(id: string): Promise { - const target = id.trim(); - if (!target) return null; - const config = await this.serverConfigService.load(); - const found = config.workspaces.find((item) => item.id === target); - return found ? toWorkspaceRecord(found) : null; - } - - async resolveWorkspace(input: { - workspaceId?: string; - cwd?: string; - }): Promise { - const workspaceId = input.workspaceId?.trim(); - if (workspaceId) { - const found = await this.getById(workspaceId); - if (!found) { - throw new NotFoundException(`workspace not found: ${workspaceId}`); - } - return found; - } - - const cwd = input.cwd?.trim(); - if (cwd) { - return this.ensureByCwd(cwd); - } - - throw new BadRequestException('workspaceId is required'); - } - - async ensureByCwd( - cwd: string, - name?: string, - options?: { validateReadable?: boolean }, - ): Promise { - const normalizedCwd = - options?.validateReadable === false - ? normalizeWorkspacePath(cwd) - : await this.resolveReadableDirectory(cwd); - const id = workspaceIdFromCwd(normalizedCwd); - - const existing = await this.getById(id); - if (existing) { - if (name && name.trim() && existing.name !== name.trim()) { - const renamed = await this.update(existing.id, { name }); - return renamed.item; - } - return existing; - } - - const now = new Date().toISOString(); - const next: WorkspaceRecord = { - id, - cwd: normalizedCwd, - name: normalizeWorkspaceName( - name ?? defaultWorkspaceName(normalizedCwd), - normalizedCwd, - ), - createdAt: now, - lastUsedAt: now, - }; - - await this.serverConfigService.updateConfig((config) => { - return { - ...config, - workspaces: [...config.workspaces, next].sort((a, b) => - a.cwd.localeCompare(b.cwd), - ), - }; - }); - - return next; - } - - async add(input: { - cwd?: unknown; - name?: unknown; - }): Promise<{ created: boolean; item: WorkspaceRecord }> { - const cwd = typeof input.cwd === 'string' ? input.cwd.trim() : ''; - if (!cwd) { - throw new BadRequestException('cwd is required'); - } - const name = typeof input.name === 'string' ? input.name.trim() : undefined; - const item = await this.ensureByCwd(cwd, name); - return { created: true, item }; - } - - async update( - workspaceId: string, - input: { name?: unknown }, - ): Promise<{ updated: boolean; item: WorkspaceRecord }> { - const id = workspaceId.trim(); - if (!id) { - throw new BadRequestException('workspaceId is required'); - } - - const found = await this.getById(id); - if (!found) { - throw new NotFoundException('workspace not found'); - } - - const name = typeof input.name === 'string' ? input.name.trim() : ''; - if (!name) { - throw new BadRequestException('name is required'); - } - - const next = { - ...found, - name, - lastUsedAt: new Date().toISOString(), - }; - - await this.serverConfigService.updateConfig((config) => ({ - ...config, - workspaces: config.workspaces.map((item) => - item.id === id ? next : item, - ), - })); - - return { updated: true, item: next }; - } - - async remove(workspaceId: string): Promise<{ deleted: boolean }> { - const id = workspaceId.trim(); - if (!id) { - throw new BadRequestException('workspaceId is required'); - } - - const found = await this.getById(id); - if (!found) { - throw new NotFoundException('workspace not found'); - } - - await this.serverConfigService.updateConfig((config) => ({ - ...config, - workspaces: config.workspaces.filter((item) => item.id !== id), - })); - - return { deleted: true }; - } - - async touchLastUsed(workspaceId: string): Promise { - const id = workspaceId.trim(); - if (!id) return; - - await this.serverConfigService.updateConfig((config) => ({ - ...config, - workspaces: config.workspaces.map((item) => - item.id === id - ? { - ...item, - lastUsedAt: new Date().toISOString(), - } - : item, - ), - })); - } - - async listDirectories( - pathInput: string | undefined, - ): Promise { - const config = await this.serverConfigService.load(); - const rootPath = config.workspaceBrowser.rootPath || '/'; - const rootRealPath = await this.resolveReadableDirectory(rootPath); - - let requestedPath = pathInput?.trim() ? pathInput.trim() : rootRealPath; - if (!pathInput?.trim() && rootRealPath === '/') { - // Use HOME as default browse entry when root is full filesystem. - try { - requestedPath = await this.resolveReadableDirectory(homedir()); - } catch { - requestedPath = rootRealPath; - } - } - const targetPath = await this.resolveReadableDirectory(requestedPath); - - if (!isWithinRoot(targetPath, rootRealPath)) { - throw new BadRequestException('path is outside workspace browser root'); - } - - let entries: import('node:fs').Dirent[]; - try { - entries = await readdir(targetPath, { withFileTypes: true }); - } catch { - throw new BadRequestException('failed to read directory'); - } - - const sortedEntries = entries - .filter((entry) => !entry.name.startsWith('.')) - .sort((a, b) => - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), - ); - - const items: WorkspaceDirEntry[] = []; - for (const entry of sortedEntries) { - if (items.length >= MAX_DIRECTORY_ITEMS) break; - const candidate = resolve(targetPath, entry.name); - - let directoryPath: string | null = null; - if (entry.isDirectory()) { - directoryPath = normalizeWorkspacePath(candidate); - } else if (entry.isSymbolicLink()) { - try { - const linked = normalizeWorkspacePath(await realpath(candidate)); - const linkedStat = await stat(linked); - if (!linkedStat.isDirectory()) continue; - if (!isWithinRoot(linked, rootRealPath)) continue; - directoryPath = linked; - } catch { - continue; - } - } else { - continue; - } - - if (!directoryPath) continue; - - let readable = true; - try { - await access(directoryPath, constants.R_OK | constants.X_OK); - } catch { - readable = false; - } - - items.push({ - name: entry.name, - path: directoryPath, - kind: 'dir', - readable, - }); - } - - const parent = dirname(targetPath); - const parentPath = - targetPath === rootRealPath || !isWithinRoot(parent, rootRealPath) - ? null - : normalizeWorkspacePath(parent); - - return { - path: normalizeWorkspacePath(targetPath), - parentPath, - items, - }; - } - - private async hydrateFromHistoryIfNeeded(): Promise { - if (this.historyHydrated) return; - this.historyHydrated = true; - - const config = await this.serverConfigService.load(); - if (config.workspaces.length > 0) return; - - const cwds = await collectHistoryCwds(); - if (cwds.length === 0) return; - - const now = new Date().toISOString(); - const hydrated = cwds.map((cwd) => ({ - id: workspaceIdFromCwd(cwd), - cwd, - name: defaultWorkspaceName(cwd), - createdAt: now, - lastUsedAt: now, - })); - - await this.serverConfigService.updateConfig((current) => ({ - ...current, - workspaces: hydrated, - })); - } - - private async resolveReadableDirectory(path: string): Promise { - const normalizedPath = normalizeWorkspacePath(path); - let realPath: string; - try { - realPath = normalizeWorkspacePath(await realpath(normalizedPath)); - } catch { - throw new BadRequestException(`directory does not exist: ${path}`); - } - - let directoryStat: import('node:fs').Stats; - try { - directoryStat = await stat(realPath); - } catch { - throw new BadRequestException(`directory is not accessible: ${path}`); - } - - if (!directoryStat.isDirectory()) { - throw new BadRequestException(`path is not a directory: ${path}`); - } - - try { - await access(realPath, constants.R_OK | constants.X_OK); - } catch { - throw new BadRequestException(`directory is not readable: ${path}`); - } - - return realPath; - } -} diff --git a/packages/web-server/src/workspaces/workspaces.types.ts b/packages/web-server/src/workspaces/workspaces.types.ts deleted file mode 100644 index d8943bf..0000000 --- a/packages/web-server/src/workspaces/workspaces.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { - WorkspaceDirEntry, - WorkspaceFsListResult, - WorkspaceRecord, - SessionRuntimeBadge, -} from '@memo-code/core'; diff --git a/packages/web-server/src/workspaces/workspaces.utils.ts b/packages/web-server/src/workspaces/workspaces.utils.ts deleted file mode 100644 index bb96b9d..0000000 --- a/packages/web-server/src/workspaces/workspaces.utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - cwdBelongsToWorkspace, - defaultWorkspaceName, - normalizeWorkspaceName, - normalizeWorkspacePath, - workspaceIdFromCwd, -} from '@memo-code/core'; diff --git a/packages/web-server/src/ws/rpc-router.service.test.ts b/packages/web-server/src/ws/rpc-router.service.test.ts deleted file mode 100644 index bb06e77..0000000 --- a/packages/web-server/src/ws/rpc-router.service.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import assert from 'node:assert'; -import { describe, expect, test, vi } from 'vitest'; -import { RpcRouterService, type RpcCallContext } from './rpc-router.service'; -import { WsRpcError } from './ws.errors'; - -function createFixture() { - const sessionsService = { - list: vi.fn(), - getSessionDetail: vi.fn(), - getSessionEvents: vi.fn(), - removeSessionsByWorkspace: vi.fn(), - }; - - const chatService = { - deleteSession: vi.fn(), - createSession: vi.fn(), - listProviders: vi.fn(), - listRuntimeBadges: vi.fn(), - getSessionState: vi.fn(), - attachSession: vi.fn(), - closeSession: vi.fn(), - suggestFiles: vi.fn(), - submitInput: vi.fn(), - removeQueuedInput: vi.fn(), - sendQueuedInputNow: vi.fn(), - cancelCurrentTurn: vi.fn(), - compactSession: vi.fn(), - applyApprovalDecision: vi.fn(), - }; - - const mcpService = { - list: vi.fn(), - get: vi.fn(), - create: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - login: vi.fn(), - logout: vi.fn(), - setActive: vi.fn(), - }; - - const skillsService = { - list: vi.fn(), - get: vi.fn(), - create: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - setActive: vi.fn(), - }; - - const workspacesService = { - list: vi.fn(), - add: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - listDirectories: vi.fn(), - }; - - const sessionRegistry = { - requireOwner: vi.fn(), - claim: vi.fn(), - release: vi.fn(), - }; - - const router = new RpcRouterService( - sessionsService as never, - chatService as never, - mcpService as never, - skillsService as never, - workspacesService as never, - sessionRegistry as never, - ); - - return { - router, - sessionsService, - chatService, - mcpService, - skillsService, - workspacesService, - sessionRegistry, - }; -} - -const context: RpcCallContext = { - connectionId: 'connection-1', - username: 'memo', -}; - -describe('RpcRouterService', () => { - test('throws METHOD_NOT_FOUND for unknown rpc method', async () => { - const { router } = createFixture(); - await assert.rejects( - () => router.dispatch(context, 'unknown.method', {}), - (error: unknown) => { - assert.ok(error instanceof WsRpcError); - assert.strictEqual(error.code, 'METHOD_NOT_FOUND'); - return true; - }, - ); - }); - - test('normalizes non-object params to empty object', async () => { - const { router, sessionsService } = createFixture(); - sessionsService.list.mockResolvedValue({ items: [] }); - - await router.dispatch(context, 'sessions.list', null); - - expect(sessionsService.list).toHaveBeenCalledWith({}); - }); - - test('checks ownership before reading session state', async () => { - const { router, chatService, sessionRegistry } = createFixture(); - chatService.getSessionState.mockReturnValue({ sessionId: 'session-1' }); - - const result = await router.dispatch(context, 'chat.session.state', { - sessionId: ' session-1 ', - }); - - expect(sessionRegistry.requireOwner).toHaveBeenCalledWith( - 'session-1', - 'connection-1', - ); - expect(chatService.getSessionState).toHaveBeenCalledWith('session-1'); - expect(result).toEqual({ sessionId: 'session-1' }); - }); - - test('releases claimed session when attach fails', async () => { - const { router, chatService, sessionRegistry } = createFixture(); - chatService.attachSession.mockRejectedValue(new Error('attach failed')); - - await expect( - router.dispatch(context, 'chat.session.attach', { sessionId: 's-1' }), - ).rejects.toThrow('attach failed'); - - expect(sessionRegistry.claim).toHaveBeenCalledWith('s-1', 'connection-1'); - expect(sessionRegistry.release).toHaveBeenCalledWith('s-1', 'connection-1'); - }); - - test('closes session and always releases ownership', async () => { - const { router, chatService, sessionRegistry } = createFixture(); - chatService.closeSession.mockResolvedValue({ closed: true }); - - const result = await router.dispatch(context, 'chat.session.close', { - sessionId: 's-2', - }); - - expect(sessionRegistry.requireOwner).toHaveBeenCalledWith( - 's-2', - 'connection-1', - ); - expect(chatService.closeSession).toHaveBeenCalledWith('s-2'); - expect(sessionRegistry.release).toHaveBeenCalledWith('s-2', 'connection-1'); - expect(result).toEqual({ closed: true }); - }); - - test('normalizes chat.session.create payload fields', async () => { - const { router, chatService } = createFixture(); - chatService.createSession.mockResolvedValue({ sessionId: 'created' }); - - await router.dispatch(context, 'chat.session.create', { - providerName: ' provider-a ', - workspaceId: ' workspace-a ', - cwd: ' /tmp/demo ', - toolPermissionMode: 'invalid', - activeMcpServers: ['s1', 1, 's2'], - }); - - expect(chatService.createSession).toHaveBeenCalledWith({ - providerName: 'provider-a', - workspaceId: 'workspace-a', - cwd: '/tmp/demo', - toolPermissionMode: undefined, - activeMcpServers: ['s1', 's2'], - }); - }); - - test('validates approval decision values', async () => { - const { router } = createFixture(); - await assert.rejects( - () => - router.dispatch(context, 'chat.approval.respond', { - sessionId: 's-1', - fingerprint: 'f-1', - decision: 'allow', - }), - (error: unknown) => { - assert.ok(error instanceof WsRpcError); - assert.strictEqual(error.code, 'BAD_REQUEST'); - assert.strictEqual( - error.message, - 'decision must be once | session | deny', - ); - return true; - }, - ); - }); - - test('normalizes and validates mcp.active.set names', async () => { - const { router, mcpService } = createFixture(); - mcpService.setActive.mockResolvedValue({ updated: true }); - - await router.dispatch(context, 'mcp.active.set', { - names: [' a ', 1, '', 'b'], - }); - - expect(mcpService.setActive).toHaveBeenCalledWith(['a', 'b']); - }); - - test('throws BAD_REQUEST when mcp.active.set names is not array', async () => { - const { router } = createFixture(); - await assert.rejects( - () => router.dispatch(context, 'mcp.active.set', { names: 'x' }), - (error: unknown) => { - assert.ok(error instanceof WsRpcError); - assert.strictEqual(error.code, 'BAD_REQUEST'); - assert.strictEqual(error.message, 'names must be string[]'); - return true; - }, - ); - }); - - test('returns merged result for workspace.remove', async () => { - const { router, sessionsService, workspacesService } = createFixture(); - sessionsService.removeSessionsByWorkspace.mockResolvedValue({ - deleted: true, - deletedSessions: 3, - }); - workspacesService.remove.mockResolvedValue({ deleted: true }); - - const result = await router.dispatch(context, 'workspace.remove', { - workspaceId: 'workspace-1', - }); - - expect(sessionsService.removeSessionsByWorkspace).toHaveBeenCalledWith( - 'workspace-1', - ); - expect(workspacesService.remove).toHaveBeenCalledWith('workspace-1'); - expect(result).toEqual({ - deleted: true, - deletedSessions: 3, - }); - }); -}); diff --git a/packages/web-server/src/ws/rpc-router.service.ts b/packages/web-server/src/ws/rpc-router.service.ts deleted file mode 100644 index 520d4bc..0000000 --- a/packages/web-server/src/ws/rpc-router.service.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ChatService } from '../chat/chat.service'; -import { McpService } from '../mcp/mcp.service'; -import { SessionsService } from '../sessions/sessions.service'; -import { SkillsService } from '../skills/skills.service'; -import { WorkspacesService } from '../workspaces/workspaces.service'; -import { SessionRuntimeRegistry } from './session-runtime-registry.service'; -import { WsRpcError } from './ws.errors'; - -export type RpcCallContext = { - connectionId: string; - username: string; -}; - -type RpcInput = Record; -type RpcHandler = ( - context: RpcCallContext, - input: RpcInput, -) => Promise | unknown; - -const TOOL_PERMISSION_MODES = ['none', 'once', 'full'] as const; -const APPROVAL_DECISIONS = ['once', 'session', 'deny'] as const; - -function asObject(input: unknown): Record { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - return {}; - } - return input as Record; -} - -function requireString( - input: Record, - key: string, - code = 'BAD_REQUEST', -): string { - const value = input[key]; - if (typeof value !== 'string' || !value.trim()) { - throw new WsRpcError(code, `${key} is required`); - } - return value.trim(); -} - -function asString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) - ? value - : undefined; -} - -function asStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - return value.filter((item): item is string => typeof item === 'string'); -} - -function requireTrimmedStringArray(input: RpcInput, key: string): string[] { - if (!Array.isArray(input[key])) { - throw new WsRpcError('BAD_REQUEST', `${key} must be string[]`); - } - return input[key] - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean); -} - -function asEnum( - value: unknown, - allowed: readonly T[], -): T | undefined { - if (typeof value !== 'string') return undefined; - return allowed.includes(value as T) ? (value as T) : undefined; -} - -function requireEnum( - value: unknown, - allowed: readonly T[], - message: string, -): T { - const parsed = asEnum(value, allowed); - if (!parsed) { - throw new WsRpcError('BAD_REQUEST', message); - } - return parsed; -} - -@Injectable() -export class RpcRouterService { - private readonly handlers: Record; - - constructor( - private readonly sessionsService: SessionsService, - private readonly chatService: ChatService, - private readonly mcpService: McpService, - private readonly skillsService: SkillsService, - private readonly workspacesService: WorkspacesService, - private readonly sessionRegistry: SessionRuntimeRegistry, - ) { - this.handlers = { - ...this.buildSessionHandlers(), - ...this.buildChatHandlers(), - ...this.buildMcpHandlers(), - ...this.buildSkillHandlers(), - ...this.buildWorkspaceHandlers(), - }; - } - - async dispatch( - context: RpcCallContext, - method: string, - params: unknown, - ): Promise { - const handler = this.handlers[method]; - if (!handler) { - throw new WsRpcError('METHOD_NOT_FOUND', `Unknown method: ${method}`); - } - return handler(context, asObject(params)); - } - - private buildSessionHandlers(): Record { - return { - 'sessions.list': (_context, input) => this.sessionsService.list(input), - 'sessions.detail': (_context, input) => - this.sessionsService.getSessionDetail( - requireString(input, 'sessionId'), - ), - 'sessions.events': (_context, input) => - this.sessionsService.getSessionEvents( - requireString(input, 'sessionId'), - input, - ), - 'sessions.remove': (_context, input) => - this.chatService.deleteSession(requireString(input, 'sessionId')), - }; - } - - private buildChatHandlers(): Record { - return { - 'chat.session.create': (_context, input) => - this.chatService.createSession({ - providerName: asString(input.providerName), - workspaceId: asString(input.workspaceId), - cwd: asString(input.cwd), - toolPermissionMode: asEnum( - input.toolPermissionMode, - TOOL_PERMISSION_MODES, - ), - activeMcpServers: asStringArray(input.activeMcpServers), - }), - 'chat.providers.list': () => this.chatService.listProviders(), - 'chat.runtimes.list': (_context, input) => - this.chatService.listRuntimeBadges({ - workspaceId: asString(input.workspaceId), - }), - 'chat.session.state': (context, input) => - this.chatService.getSessionState( - this.requireOwnedSession(input, context), - ), - 'chat.session.attach': async (context, input) => { - const sessionId = requireString(input, 'sessionId'); - this.sessionRegistry.claim(sessionId, context.connectionId); - try { - return await this.chatService.attachSession(sessionId); - } catch (error) { - this.sessionRegistry.release(sessionId, context.connectionId); - throw error; - } - }, - 'chat.session.close': (context, input) => { - const sessionId = this.requireOwnedSession(input, context); - const result = this.chatService.closeSession(sessionId); - this.sessionRegistry.release(sessionId, context.connectionId); - return result; - }, - 'chat.files.suggest': (context, input) => { - const sessionId = asString(input.sessionId); - if (sessionId) { - this.sessionRegistry.requireOwner(sessionId, context.connectionId); - } - return this.chatService.suggestFiles({ - query: typeof input.query === 'string' ? input.query : '', - limit: asFiniteNumber(input.limit), - sessionId, - workspaceId: asString(input.workspaceId), - }); - }, - 'chat.input.submit': (context, input) => - this.chatService.submitInput( - this.requireOwnedSession(input, context), - requireString(input, 'input'), - ), - 'chat.queue.remove': (context, input) => - this.chatService.removeQueuedInput( - this.requireOwnedSession(input, context), - requireString(input, 'queueId'), - ), - 'chat.queue.send_now': (context, input) => - this.chatService.sendQueuedInputNow( - this.requireOwnedSession(input, context), - ), - 'chat.turn.cancel': (context, input) => - this.chatService.cancelCurrentTurn( - this.requireOwnedSession(input, context), - ), - 'chat.session.compact': (context, input) => - this.chatService.compactSession( - this.requireOwnedSession(input, context), - ), - 'chat.approval.respond': (context, input) => - this.chatService.applyApprovalDecision( - this.requireOwnedSession(input, context), - requireString(input, 'fingerprint'), - requireEnum( - input.decision, - APPROVAL_DECISIONS, - 'decision must be once | session | deny', - ), - ), - }; - } - - private buildMcpHandlers(): Record { - return { - 'mcp.servers.list': () => this.mcpService.list(), - 'mcp.servers.get': (_context, input) => - this.mcpService.get(requireString(input, 'name')), - 'mcp.servers.create': (_context, input) => - this.mcpService.create(requireString(input, 'name'), input.config), - 'mcp.servers.update': (_context, input) => - this.mcpService.update(requireString(input, 'name'), input.config), - 'mcp.servers.remove': (_context, input) => - this.mcpService.remove(requireString(input, 'name')), - 'mcp.servers.login': (_context, input) => - this.mcpService.login( - requireString(input, 'name'), - asStringArray(input.scopes), - ), - 'mcp.servers.logout': (_context, input) => - this.mcpService.logout(requireString(input, 'name')), - 'mcp.active.set': (_context, input) => - this.mcpService.setActive(requireTrimmedStringArray(input, 'names')), - }; - } - - private buildSkillHandlers(): Record { - return { - 'skills.list': (_context, input) => - this.skillsService.list({ - scope: input.scope, - q: input.q, - workspaceId: input.workspaceId, - }), - 'skills.get': (_context, input) => - this.skillsService.get(requireString(input, 'id')), - 'skills.create': (_context, input) => - this.skillsService.create({ - scope: input.scope, - name: input.name, - description: input.description, - content: input.content, - workspaceId: input.workspaceId, - }), - 'skills.update': (_context, input) => - this.skillsService.update(requireString(input, 'id'), { - description: input.description, - content: input.content, - }), - 'skills.remove': (_context, input) => - this.skillsService.remove(requireString(input, 'id')), - 'skills.active.set': (_context, input) => - this.skillsService.setActive(requireTrimmedStringArray(input, 'ids')), - }; - } - - private buildWorkspaceHandlers(): Record { - return { - 'workspace.list': () => this.workspacesService.list(), - 'workspace.add': (_context, input) => - this.workspacesService.add({ - cwd: input.cwd, - name: input.name, - }), - 'workspace.update': (_context, input) => - this.workspacesService.update(requireString(input, 'workspaceId'), { - name: input.name, - }), - 'workspace.remove': async (_context, input) => { - const workspaceId = requireString(input, 'workspaceId'); - const sessionsResult = - await this.sessionsService.removeSessionsByWorkspace(workspaceId); - const workspaceResult = - await this.workspacesService.remove(workspaceId); - return { - ...workspaceResult, - deletedSessions: sessionsResult.deletedSessions, - }; - }, - 'workspace.fs.list': (_context, input) => - this.workspacesService.listDirectories(asString(input.path)), - }; - } - - private requireOwnedSession( - input: RpcInput, - context: RpcCallContext, - ): string { - const sessionId = requireString(input, 'sessionId'); - this.sessionRegistry.requireOwner(sessionId, context.connectionId); - return sessionId; - } -} diff --git a/packages/web-server/src/ws/session-runtime-registry.service.ts b/packages/web-server/src/ws/session-runtime-registry.service.ts deleted file mode 100644 index ff4f039..0000000 --- a/packages/web-server/src/ws/session-runtime-registry.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { WsRpcError } from './ws.errors'; - -type RuntimeRecord = { - ownerConnectionId: string; - runtimeHandle?: unknown; - historyFilePath?: string; - workspaceId?: string; - status?: 'idle' | 'running' | 'closed'; - updatedAt?: string; -}; - -@Injectable() -export class SessionRuntimeRegistry { - private readonly sessionMap = new Map(); - private readonly connectionSessions = new Map>(); - - claim(sessionId: string, connectionId: string): void { - const existing = this.sessionMap.get(sessionId); - if (existing && existing.ownerConnectionId !== connectionId) { - throw new WsRpcError( - 'SESSION_OCCUPIED', - `Session ${sessionId} is already attached by another connection.`, - ); - } - - this.sessionMap.set(sessionId, { - ownerConnectionId: connectionId, - runtimeHandle: existing?.runtimeHandle, - historyFilePath: existing?.historyFilePath, - workspaceId: existing?.workspaceId, - status: existing?.status, - updatedAt: new Date().toISOString(), - }); - - let set = this.connectionSessions.get(connectionId); - if (!set) { - set = new Set(); - this.connectionSessions.set(connectionId, set); - } - set.add(sessionId); - } - - release(sessionId: string, connectionId: string): void { - const record = this.sessionMap.get(sessionId); - if (!record) return; - if (record.ownerConnectionId !== connectionId) return; - - this.sessionMap.delete(sessionId); - const set = this.connectionSessions.get(connectionId); - if (!set) return; - set.delete(sessionId); - if (set.size === 0) { - this.connectionSessions.delete(connectionId); - } - } - - releaseAll(connectionId: string): string[] { - const set = this.connectionSessions.get(connectionId); - if (!set || set.size === 0) return []; - - const released = Array.from(set.values()); - for (const sessionId of released) { - const record = this.sessionMap.get(sessionId); - if (record && record.ownerConnectionId === connectionId) { - this.sessionMap.delete(sessionId); - } - } - this.connectionSessions.delete(connectionId); - return released; - } - - isOwner(sessionId: string, connectionId: string): boolean { - const record = this.sessionMap.get(sessionId); - return record?.ownerConnectionId === connectionId; - } - - requireOwner(sessionId: string, connectionId: string): void { - if (!this.isOwner(sessionId, connectionId)) { - throw new WsRpcError( - 'SESSION_NOT_ATTACHED', - `Session ${sessionId} is not attached.`, - ); - } - } - - setRuntime(sessionId: string, runtimeHandle: unknown): void { - const record = this.sessionMap.get(sessionId); - if (!record) return; - record.runtimeHandle = runtimeHandle; - } - - setHistoryFilePath(sessionId: string, historyFilePath: string): void { - const record = this.sessionMap.get(sessionId); - if (!record) return; - record.historyFilePath = historyFilePath; - } - - setWorkspaceId(sessionId: string, workspaceId: string): void { - const record = this.sessionMap.get(sessionId); - if (!record) return; - record.workspaceId = workspaceId; - record.updatedAt = new Date().toISOString(); - } - - setStatus(sessionId: string, status: 'idle' | 'running' | 'closed'): void { - const record = this.sessionMap.get(sessionId); - if (!record) return; - record.status = status; - record.updatedAt = new Date().toISOString(); - } - - listAll(): Array<{ - sessionId: string; - ownerConnectionId: string; - workspaceId?: string; - status?: 'idle' | 'running' | 'closed'; - updatedAt?: string; - }> { - return Array.from(this.sessionMap.entries()).map(([sessionId, value]) => ({ - sessionId, - ownerConnectionId: value.ownerConnectionId, - workspaceId: value.workspaceId, - status: value.status, - updatedAt: value.updatedAt, - })); - } - - listByWorkspace(workspaceId: string): Array<{ - sessionId: string; - ownerConnectionId: string; - workspaceId?: string; - status?: 'idle' | 'running' | 'closed'; - updatedAt?: string; - }> { - return this.listAll().filter((item) => item.workspaceId === workspaceId); - } - - get(sessionId: string): RuntimeRecord | null { - return this.sessionMap.get(sessionId) ?? null; - } -} diff --git a/packages/web-server/src/ws/ws-event-bus.service.ts b/packages/web-server/src/ws/ws-event-bus.service.ts deleted file mode 100644 index 28ea639..0000000 --- a/packages/web-server/src/ws/ws-event-bus.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import type { RpcEventFrame } from './ws.types'; - -@Injectable() -export class WsEventBus { - private seq = 0; - - create(topic: string, data: unknown): RpcEventFrame { - this.seq += 1; - return { - type: 'event', - topic, - data, - seq: this.seq, - ts: new Date().toISOString(), - }; - } -} diff --git a/packages/web-server/src/ws/ws-gateway.module.ts b/packages/web-server/src/ws/ws-gateway.module.ts deleted file mode 100644 index a1a3b73..0000000 --- a/packages/web-server/src/ws/ws-gateway.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ChatModule } from '../chat/chat.module'; -import { McpModule } from '../mcp/mcp.module'; -import { SessionsModule } from '../sessions/sessions.module'; -import { SkillsModule } from '../skills/skills.module'; -import { StreamModule } from '../stream/stream.module'; -import { WorkspacesModule } from '../workspaces/workspaces.module'; -import { RpcRouterService } from './rpc-router.service'; -import { SessionRuntimeRegistry } from './session-runtime-registry.service'; -import { WsEventBus } from './ws-event-bus.service'; -import { WsGatewayService } from './ws-gateway.service'; - -@Module({ - imports: [ - SessionsModule, - ChatModule, - McpModule, - SkillsModule, - StreamModule, - WorkspacesModule, - ], - providers: [ - WsGatewayService, - RpcRouterService, - SessionRuntimeRegistry, - WsEventBus, - ], - exports: [WsGatewayService, SessionRuntimeRegistry], -}) -export class WsGatewayModule {} diff --git a/packages/web-server/src/ws/ws-gateway.service.ts b/packages/web-server/src/ws/ws-gateway.service.ts deleted file mode 100644 index a2bdb63..0000000 --- a/packages/web-server/src/ws/ws-gateway.service.ts +++ /dev/null @@ -1,707 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { IncomingMessage } from 'node:http'; -import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { WebSocketServer, type RawData, type WebSocket } from 'ws'; -import type { AccessTokenPayload } from '../auth/auth.types'; -import type { ChatSessionSnapshot } from '../chat/chat.types'; -import { StreamService } from '../stream/stream.service'; -import { RpcRouterService } from './rpc-router.service'; -import { SessionRuntimeRegistry } from './session-runtime-registry.service'; -import { - WS_CLOSE_NOT_FOUND, - WS_CLOSE_SESSION_OCCUPIED, - WS_CLOSE_UNAUTHORIZED, - WsRpcError, -} from './ws.errors'; -import { WsEventBus } from './ws-event-bus.service'; -import type { - RpcRequestFrame, - RpcResponseFrame, - WsConnectionContext, -} from './ws.types'; - -type AccessTokenVerifier = (token: string) => Promise; - -type AttachOptions = { - httpServer: { - on: ( - event: 'upgrade', - listener: ( - request: IncomingMessage, - socket: import('node:net').Socket, - head: Buffer, - ) => void, - ) => void; - }; - verifyAccessToken: AccessTokenVerifier; -}; - -type ConnectionState = WsConnectionContext & { - requestWindow: number[]; - sessionSubscriptions: Map void>; -}; - -type StreamPayload = - | { - type: 'turn.start'; - payload: { turn: number; input: string; promptTokens?: number }; - } - | { - type: 'context.usage'; - payload: { - turn: number; - step: number; - phase: 'turn_start' | 'step_start' | 'post_compact'; - promptTokens: number; - contextWindow: number; - thresholdTokens: number; - usagePercent: number; - }; - } - | { - type: 'assistant.chunk'; - payload: { turn: number; step: number; chunk: string }; - } - | { - type: 'turn.final'; - payload: { - turn: number; - finalText: string; - status: string; - errorMessage?: string; - }; - } - | { - type: 'session.status'; - payload: { - status: 'idle' | 'running' | 'closed'; - workspaceId?: string; - updatedAt?: string; - }; - } - | { - type: 'session.snapshot'; - payload: unknown; - } - | { - type: 'system.message'; - payload: { - title: string; - content: string; - }; - } - | { - type: 'approval.request'; - payload: { - fingerprint: string; - toolName: string; - reason: string; - riskLevel: string; - params: unknown; - }; - } - | { - type: 'tool.action'; - payload: { - turn: number; - step: number; - action: { tool: string; input: unknown }; - parallelActions?: Array<{ tool: string; input: unknown }>; - thinking?: string; - }; - } - | { - type: 'tool.observation'; - payload: { - turn: number; - step: number; - observation: string; - resultStatus?: string; - parallelResultStatuses?: string[]; - }; - } - | { - type: 'error'; - payload: { - code: string; - message: string; - }; - }; - -const WS_PATH = '/api/ws'; -const MAX_REQUEST_BYTES = 256 * 1024; -const MAX_REQUESTS_PER_MINUTE = 120; -const REQUEST_TIMEOUT_MS = 20_000; - -function readBearerTokenFromHeader( - authorization: string | string[] | undefined, -): string | null { - if (!authorization) return null; - const raw = Array.isArray(authorization) ? authorization[0] : authorization; - if (!raw) return null; - const [scheme, token] = raw.split(' '); - if (scheme !== 'Bearer' || !token) return null; - const trimmed = token.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function asRpcRequest(input: unknown): RpcRequestFrame { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new WsRpcError('BAD_FRAME', 'Invalid frame payload'); - } - - const frame = input as Partial; - if (frame.type !== 'rpc.request') { - throw new WsRpcError('BAD_FRAME', 'Frame type must be rpc.request'); - } - if (typeof frame.id !== 'string' || !frame.id.trim()) { - throw new WsRpcError('BAD_FRAME', 'Frame id is required'); - } - if (typeof frame.method !== 'string' || !frame.method.trim()) { - throw new WsRpcError('BAD_FRAME', 'Frame method is required'); - } - - return { - id: frame.id, - type: 'rpc.request', - method: frame.method, - params: frame.params, - }; -} - -function rawByteLength(raw: RawData): number { - if (typeof raw === 'string') { - return Buffer.byteLength(raw); - } - if (raw instanceof ArrayBuffer) { - return raw.byteLength; - } - if (Array.isArray(raw)) { - return raw.reduce((total, chunk) => total + chunk.byteLength, 0); - } - return raw.byteLength; -} - -function rawToUtf8(raw: RawData): string { - if (typeof raw === 'string') { - return raw; - } - if (raw instanceof ArrayBuffer) { - return Buffer.from(raw).toString('utf8'); - } - if (Array.isArray(raw)) { - return Buffer.concat(raw.map((chunk) => Buffer.from(chunk))).toString( - 'utf8', - ); - } - return raw.toString('utf8'); -} - -@Injectable() -export class WsGatewayService implements OnModuleDestroy { - private readonly logger = new Logger(WsGatewayService.name); - private readonly connections = new Map(); - private wsServer: WebSocketServer | null = null; - private attached = false; - private globalStreamUnsubscribe: (() => void) | null = null; - - constructor( - private readonly rpcRouter: RpcRouterService, - private readonly streamService: StreamService, - private readonly sessionRegistry: SessionRuntimeRegistry, - private readonly eventBus: WsEventBus, - ) {} - - attach(options: AttachOptions): void { - if (this.attached) return; - this.attached = true; - - this.wsServer = new WebSocketServer({ noServer: true }); - this.globalStreamUnsubscribe = this.streamService.subscribeAll( - (sessionId, payload) => { - const data = this.mapRuntimeStatusPayload(sessionId, payload); - if (!data) return; - this.sessionRegistry.setWorkspaceId(sessionId, data.workspaceId); - this.sessionRegistry.setStatus(sessionId, data.status); - this.broadcastEventToAll('chat.runtime.status', data); - }, - ); - - options.httpServer.on('upgrade', (request, socket, head) => { - const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); - if (requestUrl.pathname !== WS_PATH || !this.wsServer) return; - - this.wsServer.handleUpgrade(request, socket, head, (ws) => { - void this.handleConnection(ws, request, options.verifyAccessToken); - }); - }); - } - - async onModuleDestroy(): Promise { - if (!this.wsServer) return; - - if (this.globalStreamUnsubscribe) { - this.globalStreamUnsubscribe(); - this.globalStreamUnsubscribe = null; - } - - for (const connection of this.connections.values()) { - for (const unsubscribe of connection.sessionSubscriptions.values()) { - unsubscribe(); - } - connection.sessionSubscriptions.clear(); - connection.socket.close(1001, 'server shutdown'); - } - this.connections.clear(); - - await new Promise((resolve) => { - this.wsServer?.close(() => resolve()); - }); - this.wsServer = null; - } - - private async handleConnection( - socket: WebSocket, - request: IncomingMessage, - verifyAccessToken: AccessTokenVerifier, - ): Promise { - const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); - const queryToken = - requestUrl.searchParams.get('accessToken')?.trim() ?? null; - const headerToken = readBearerTokenFromHeader( - request.headers.authorization, - ); - const accessToken = headerToken ?? queryToken; - - if (!accessToken) { - socket.close(WS_CLOSE_UNAUTHORIZED, 'UNAUTHORIZED'); - return; - } - - let payload: AccessTokenPayload; - try { - payload = await verifyAccessToken(accessToken); - } catch { - socket.close(WS_CLOSE_UNAUTHORIZED, 'UNAUTHORIZED'); - return; - } - - const connection: ConnectionState = { - id: randomUUID(), - socket, - username: payload.sub, - requestWindow: [], - sessionSubscriptions: new Map void>(), - }; - - this.connections.set(connection.id, connection); - - socket.on('message', (raw: RawData) => { - void this.handleMessage(connection, raw); - }); - - socket.on('close', () => { - this.cleanupConnection(connection.id); - }); - - socket.on('error', (error) => { - this.logger.warn(`ws connection error: ${error.message}`); - this.cleanupConnection(connection.id); - }); - } - - private async handleMessage( - connection: ConnectionState, - raw: RawData, - ): Promise { - if (rawByteLength(raw) > MAX_REQUEST_BYTES) { - connection.socket.close(1009, 'FRAME_TOO_LARGE'); - return; - } - - const now = Date.now(); - let removeCount = 0; - while ( - removeCount < connection.requestWindow.length && - now - connection.requestWindow[removeCount]! >= 60_000 - ) { - removeCount += 1; - } - if (removeCount > 0) { - connection.requestWindow.splice(0, removeCount); - } - connection.requestWindow.push(now); - if (connection.requestWindow.length > MAX_REQUESTS_PER_MINUTE) { - this.sendRpcError( - connection, - null, - new WsRpcError( - 'RATE_LIMITED', - 'Too many requests on this websocket connection.', - ), - ); - return; - } - - let parsed: RpcRequestFrame; - try { - const payload = JSON.parse(rawToUtf8(raw)) as unknown; - parsed = asRpcRequest(payload); - } catch (error) { - const wsError = - error instanceof WsRpcError - ? error - : new WsRpcError( - 'BAD_FRAME', - error instanceof Error ? error.message : 'Invalid frame', - ); - this.sendRpcError(connection, null, wsError); - return; - } - - const timer = setTimeout(() => { - settled = true; - this.sendRpcError( - connection, - parsed.id, - new WsRpcError('TIMEOUT', `RPC timeout after ${REQUEST_TIMEOUT_MS}ms`), - ); - }, REQUEST_TIMEOUT_MS); - let settled = false; - - try { - const result = await this.rpcRouter.dispatch( - { - connectionId: connection.id, - username: connection.username, - }, - parsed.method, - parsed.params, - ); - - if (parsed.method === 'chat.session.attach') { - const params = - parsed.params && - typeof parsed.params === 'object' && - !Array.isArray(parsed.params) - ? (parsed.params as Record) - : {}; - const sessionId = - typeof params.sessionId === 'string' ? params.sessionId : ''; - if (sessionId) { - this.bindSession(connection, sessionId); - } - - const snapshot = result as ChatSessionSnapshot; - if (snapshot?.state?.workspaceId) { - this.sessionRegistry.setWorkspaceId( - sessionId, - snapshot.state.workspaceId, - ); - } - this.sendEvent(connection, 'chat.session.snapshot', { - sessionId, - state: snapshot.state, - turns: snapshot.turns, - }); - } - - if ( - parsed.method === 'chat.session.close' || - parsed.method === 'sessions.remove' - ) { - const params = - parsed.params && - typeof parsed.params === 'object' && - !Array.isArray(parsed.params) - ? (parsed.params as Record) - : {}; - const sessionId = - typeof params.sessionId === 'string' ? params.sessionId : ''; - if (sessionId) { - this.unbindSession(connection, sessionId); - } - } - - if ( - parsed.method === 'workspace.add' || - parsed.method === 'workspace.update' || - parsed.method === 'workspace.remove' - ) { - this.broadcastEventToAll('workspace.changed', { - action: parsed.method, - payload: result, - }); - } - - if (settled) return; - settled = true; - this.sendRpcSuccess(connection, parsed.id, result); - } catch (error) { - if (settled) return; - settled = true; - const wsError = this.toWsRpcError(error); - this.sendRpcError(connection, parsed.id, wsError); - if ( - parsed.method === 'chat.session.attach' && - wsError.code === 'SESSION_OCCUPIED' - ) { - connection.socket.close(WS_CLOSE_SESSION_OCCUPIED, 'SESSION_OCCUPIED'); - } - if ( - parsed.method === 'chat.session.attach' && - wsError.code === 'SESSION_NOT_FOUND' - ) { - connection.socket.close(WS_CLOSE_NOT_FOUND, 'SESSION_NOT_FOUND'); - } - } finally { - clearTimeout(timer); - } - } - - private bindSession(connection: ConnectionState, sessionId: string): void { - if (connection.sessionSubscriptions.has(sessionId)) { - return; - } - - const unsubscribe = this.streamService.subscribe(sessionId, (payload) => { - const mapped = this.mapStreamPayload(sessionId, payload); - if (!mapped) return; - this.sendEvent(connection, mapped.topic, mapped.data); - }); - - connection.sessionSubscriptions.set(sessionId, unsubscribe); - } - - private unbindSession(connection: ConnectionState, sessionId: string): void { - const unsubscribe = connection.sessionSubscriptions.get(sessionId); - if (unsubscribe) { - unsubscribe(); - connection.sessionSubscriptions.delete(sessionId); - } - } - - private cleanupConnection(connectionId: string): void { - const connection = this.connections.get(connectionId); - if (!connection) return; - - const released = this.sessionRegistry.releaseAll(connection.id); - for (const sessionId of released) { - this.unbindSession(connection, sessionId); - } - for (const sessionId of Array.from( - connection.sessionSubscriptions.keys(), - )) { - this.unbindSession(connection, sessionId); - } - - this.connections.delete(connectionId); - } - - private mapStreamPayload( - sessionId: string, - payload: unknown, - ): { topic: string; data: unknown } | null { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return null; - } - - const frame = payload as StreamPayload; - - if (frame.type === 'turn.start') { - return { - topic: 'chat.turn.start', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'assistant.chunk') { - return { - topic: 'chat.turn.chunk', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'context.usage') { - return { - topic: 'chat.context.usage', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'turn.final') { - return { - topic: 'chat.turn.final', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'session.status') { - return { - topic: 'chat.session.status', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'session.snapshot') { - return { - topic: 'chat.session.state', - data: { sessionId, state: frame.payload }, - }; - } - - if (frame.type === 'system.message') { - return { - topic: 'chat.system.message', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'approval.request') { - return { - topic: 'chat.approval.request', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'tool.action') { - return { - topic: 'chat.tool.action', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'tool.observation') { - return { - topic: 'chat.tool.observation', - data: { sessionId, ...frame.payload }, - }; - } - - if (frame.type === 'error') { - return { - topic: 'chat.error', - data: { sessionId, ...frame.payload }, - }; - } - - return null; - } - - private mapRuntimeStatusPayload( - sessionId: string, - payload: unknown, - ): { - sessionId: string; - status: 'idle' | 'running' | 'closed'; - workspaceId: string; - updatedAt: string; - } | null { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return null; - } - const frame = payload as StreamPayload; - if (frame.type !== 'session.status') return null; - - const workspaceId = frame.payload.workspaceId; - const updatedAt = frame.payload.updatedAt; - if (typeof workspaceId !== 'string' || !workspaceId.trim()) return null; - if (typeof updatedAt !== 'string' || !updatedAt.trim()) return null; - - return { - sessionId, - status: frame.payload.status, - workspaceId, - updatedAt, - }; - } - - private sendRpcSuccess( - connection: ConnectionState, - id: string, - data: unknown, - ): void { - const response: RpcResponseFrame = { - id, - type: 'rpc.response', - ok: true, - data, - }; - this.sendSerialized(connection, JSON.stringify(response)); - } - - private sendRpcError( - connection: ConnectionState, - id: string | null, - error: WsRpcError, - ): void { - const response: RpcResponseFrame = { - id: id ?? randomUUID(), - type: 'rpc.response', - ok: false, - error: { - code: error.code, - message: error.message, - ...(error.details === undefined ? {} : { details: error.details }), - }, - }; - this.sendSerialized(connection, JSON.stringify(response)); - } - - private sendEvent( - connection: ConnectionState, - topic: string, - data: unknown, - ): void { - const eventFrame = this.eventBus.create(topic, data); - this.sendSerialized(connection, JSON.stringify(eventFrame)); - } - - private broadcastEventToAll(topic: string, data: unknown): void { - if (this.connections.size === 0) return; - const eventFrame = this.eventBus.create(topic, data); - const encoded = JSON.stringify(eventFrame); - for (const connection of this.connections.values()) { - this.sendSerialized(connection, encoded); - } - } - - private sendSerialized(connection: ConnectionState, encoded: string): void { - if (connection.socket.readyState !== connection.socket.OPEN) { - return; - } - try { - connection.socket.send(encoded); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn( - `ws send failed connection=${connection.id}: ${message}`, - ); - this.cleanupConnection(connection.id); - } - } - - private toWsRpcError(error: unknown): WsRpcError { - if (error instanceof WsRpcError) { - return error; - } - - if (error && typeof error === 'object' && 'message' in error) { - const message = - typeof (error as { message?: unknown }).message === 'string' - ? ((error as { message: string }).message ?? 'Request failed') - : 'Request failed'; - - if ( - message.toLowerCase().includes('not found') || - message.toLowerCase().includes('session not found') - ) { - return new WsRpcError('SESSION_NOT_FOUND', message); - } - - return new WsRpcError('RPC_ERROR', message); - } - - return new WsRpcError('RPC_ERROR', 'Request failed'); - } -} diff --git a/packages/web-server/src/ws/ws.errors.ts b/packages/web-server/src/ws/ws.errors.ts deleted file mode 100644 index c958fb9..0000000 --- a/packages/web-server/src/ws/ws.errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class WsRpcError extends Error { - constructor( - readonly code: string, - message: string, - readonly details?: unknown, - ) { - super(message); - } -} - -export const WS_CLOSE_UNAUTHORIZED = 4401; -export const WS_CLOSE_NOT_FOUND = 4404; -export const WS_CLOSE_SESSION_OCCUPIED = 4409; diff --git a/packages/web-server/src/ws/ws.types.ts b/packages/web-server/src/ws/ws.types.ts deleted file mode 100644 index 4ee7464..0000000 --- a/packages/web-server/src/ws/ws.types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { WebSocket } from 'ws'; - -export type RpcRequestFrame = { - id: string; - type: 'rpc.request'; - method: string; - params?: unknown; -}; - -export type RpcResponseFrame = - | { - id: string; - type: 'rpc.response'; - ok: true; - data: unknown; - } - | { - id: string; - type: 'rpc.response'; - ok: false; - error: { - code: string; - message: string; - details?: unknown; - }; - }; - -export type RpcEventFrame = { - type: 'event'; - topic: string; - data: unknown; - seq: number; - ts: string; -}; - -export type WsConnectionContext = { - id: string; - socket: WebSocket; - username: string; -}; diff --git a/packages/web-server/tsconfig.build.json b/packages/web-server/tsconfig.build.json deleted file mode 100644 index 64f86c6..0000000 --- a/packages/web-server/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/packages/web-server/tsconfig.json b/packages/web-server/tsconfig.json deleted file mode 100644 index 95b7998..0000000 --- a/packages/web-server/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "resolvePackageJsonExports": true, - "esModuleInterop": true, - "isolatedModules": true, - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2023", - "sourceMap": false, - "outDir": "./dist", - "baseUrl": "./", - "paths": { - "@memo-code/core": ["../core/dist/index.d.ts"], - "@memo-code/core/*": ["../core/dist/*"] - }, - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false - } -} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index c181a24..4cad107 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -16,7 +16,6 @@ "@streamdown/cjk": "^1.0.2", "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", - "@streamdown/mermaid": "^1.0.2", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.5", "class-variance-authority": "^0.7.1", diff --git a/packages/web-ui/src/api/auth.ts b/packages/web-ui/src/api/auth.ts index f998ad3..f739cd7 100644 --- a/packages/web-ui/src/api/auth.ts +++ b/packages/web-ui/src/api/auth.ts @@ -1,26 +1,20 @@ import { request } from '@/api/request' import type { AuthTokenPair } from '@/api/types' -export function login(params: { username: string; password: string }) { +export function login(params: { password: string }) { return request({ method: 'POST', url: '/api/auth/login', - data: params, + data: { + password: params.password, + }, }) } -export function refreshToken(params: { refreshToken: string }) { - return request({ - method: 'POST', - url: '/api/auth/refresh', - data: params, - }) +export async function refreshToken(): Promise { + throw new Error('Token refresh is not supported by core HTTP server') } -export function logout(params: { refreshToken: string }) { - return request<{ loggedOut: boolean }>({ - method: 'POST', - url: '/api/auth/logout', - data: params, - }) +export async function logout(): Promise<{ loggedOut: boolean }> { + return { loggedOut: true } } diff --git a/packages/web-ui/src/api/chat.ts b/packages/web-ui/src/api/chat.ts index bf7d64c..2a97523 100644 --- a/packages/web-ui/src/api/chat.ts +++ b/packages/web-ui/src/api/chat.ts @@ -1,63 +1,145 @@ -import { wsRequest } from '@/api/ws-client' +import { request } from '@/api/request' import type { ChatFileSuggestionResponse, ChatRuntimeListResponse, ChatSessionSnapshot, ChatProviderRecord, LiveSessionState, + SessionDetail, SessionInputResult, } from '@/api/types' +function toChatProvider(item: { + name: string + model: string + isCurrent?: boolean +}): ChatProviderRecord { + return { + name: item.name, + model: item.model, + isCurrent: item.isCurrent === true, + } +} + +function toSnapshotTurns(detail: SessionDetail | null | undefined): ChatSessionSnapshot['turns'] { + if (!detail?.turns || !Array.isArray(detail.turns)) return [] + return detail.turns.map((turn) => ({ + turn: turn.turn, + input: turn.input ?? '', + assistant: turn.finalText ?? '', + status: turn.status ?? 'ok', + errorMessage: turn.errorMessage, + steps: turn.steps, + })) +} + export function createLiveSession(params?: { providerName?: string - workspaceId?: string cwd?: string toolPermissionMode?: 'none' | 'once' | 'full' activeMcpServers?: string[] }) { - return wsRequest('chat.session.create', params ?? {}) + return request({ + method: 'POST', + url: '/api/chat/sessions', + data: params ?? {}, + }) } -export function listChatProviders() { - return wsRequest('chat.providers.list', {}) +export async function listChatProviders(): Promise { + const response = await request<{ + items: Array<{ name: string; model: string; isCurrent?: boolean }> + }>({ + method: 'GET', + url: '/api/chat/sessions/providers', + }) + + return response.items.map(toChatProvider) } -export function listChatRuntimes(params?: { workspaceId?: string }) { - return wsRequest('chat.runtimes.list', params ?? {}) +export function listChatRuntimes(params?: { workspaceCwd?: string }) { + return request({ + method: 'GET', + url: '/api/chat/runtimes', + params: params?.workspaceCwd ? { workspaceCwd: params.workspaceCwd } : undefined, + }) } export function getLiveSession(sessionId: string) { - return wsRequest('chat.session.state', { sessionId }) + return request({ + method: 'GET', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}`, + }) } -export function attachLiveSession(sessionId: string) { - return wsRequest('chat.session.attach', { sessionId }) +export async function attachLiveSession(sessionId: string): Promise { + const state = await getLiveSession(sessionId) + + let detail: SessionDetail | null = null + try { + detail = await request({ + method: 'GET', + url: `/api/sessions/${encodeURIComponent(sessionId)}`, + }) + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : '' + if (!message.includes('session not found')) { + throw error + } + } + + return { + state, + turns: toSnapshotTurns(detail), + } } export function submitSessionInput(sessionId: string, input: string) { - return wsRequest( - 'chat.input.submit', - { sessionId, input }, - { timeoutMs: null }, - ) + return request({ + method: 'POST', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/input`, + data: { input }, + timeout: 0, + }) } export function removeQueuedInput(sessionId: string, queueId: string) { - return wsRequest<{ removed: boolean }>('chat.queue.remove', { sessionId, queueId }) + return request<{ removed: boolean; queued: number }>({ + method: 'DELETE', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/queue/${encodeURIComponent(queueId)}`, + }) } export function sendQueuedInputNow(sessionId: string) { - return wsRequest<{ triggered: boolean }>('chat.queue.send_now', { sessionId }) + return request<{ triggered: boolean; queued: number }>({ + method: 'POST', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/queue/send_now`, + data: {}, + }) } export function cancelSessionTurn(sessionId: string) { - return wsRequest<{ cancelled: boolean }>('chat.turn.cancel', { sessionId }) + return request<{ cancelled: boolean }>({ + method: 'POST', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, + data: {}, + }) } -export function compactSession(sessionId: string) { - return wsRequest<{ compacted: boolean; keptMessages: number }>('chat.session.compact', { - sessionId, +export async function compactSession(sessionId: string) { + const response = await request<{ + status: string + keptMessages: number + }>({ + method: 'POST', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, + data: {}, }) + + return { + compacted: response.status === 'success', + keptMessages: response.keptMessages, + } } export function approveSessionAction( @@ -65,18 +147,25 @@ export function approveSessionAction( fingerprint: string, decision: 'once' | 'session' | 'deny', ) { - return wsRequest<{ recorded: boolean }>('chat.approval.respond', { - sessionId, - fingerprint, - decision, + return request<{ recorded: boolean }>({ + method: 'POST', + url: `/api/chat/sessions/${encodeURIComponent(sessionId)}/approval`, + data: { + fingerprint, + decision, + }, }) } export function suggestChatFiles(params: { query: string sessionId?: string - workspaceId?: string + workspaceCwd?: string limit?: number }) { - return wsRequest('chat.files.suggest', params) + return request({ + method: 'POST', + url: '/api/chat/files/suggest', + data: params, + }) } diff --git a/packages/web-ui/src/api/index.ts b/packages/web-ui/src/api/index.ts index 10b028d..acb5ef5 100644 --- a/packages/web-ui/src/api/index.ts +++ b/packages/web-ui/src/api/index.ts @@ -4,7 +4,6 @@ export * as chatApi from '@/api/chat' export * as mcpApi from '@/api/mcp' export * as skillsApi from '@/api/skills' export * as workspacesApi from '@/api/workspaces' -export { disconnectWs, onWsReconnect, wsRequest, wsSubscribe } from '@/api/ws-client' export { clearAuthTokens, getAuthTokens, request, setAuthTokens } from '@/api/request' @@ -25,6 +24,4 @@ export type { WorkspaceDirEntry, WorkspaceFsListResult, WorkspaceRecord, - WsEventFrame, - WsServerEvent, } from '@/api/types' diff --git a/packages/web-ui/src/api/mcp.ts b/packages/web-ui/src/api/mcp.ts index 239c6c5..7feefb6 100644 --- a/packages/web-ui/src/api/mcp.ts +++ b/packages/web-ui/src/api/mcp.ts @@ -1,37 +1,63 @@ -import { wsRequest } from '@/api/ws-client' +import { request } from '@/api/request' import type { McpServerConfig, McpServerRecord } from '@/api/types' export function getMcpServers() { - return wsRequest<{ items: McpServerRecord[] }>('mcp.servers.list', {}) + return request<{ items: McpServerRecord[] }>({ + method: 'GET', + url: '/api/mcp/servers', + }) } export function getMcpServer(name: string) { - return wsRequest('mcp.servers.get', { name }) + return request({ + method: 'GET', + url: `/api/mcp/servers/${encodeURIComponent(name)}`, + }) } export function createMcpServer(name: string, config: McpServerConfig) { - return wsRequest<{ created: true }>('mcp.servers.create', { name, config }) + return request<{ created: true }>({ + method: 'POST', + url: '/api/mcp/servers', + data: { name, config }, + }) } export function updateMcpServer(name: string, config: McpServerConfig) { - return wsRequest<{ updated: true }>('mcp.servers.update', { name, config }) + return request<{ updated: true }>({ + method: 'PUT', + url: `/api/mcp/servers/${encodeURIComponent(name)}`, + data: { config }, + }) } export function removeMcpServer(name: string) { - return wsRequest<{ deleted: true }>('mcp.servers.remove', { name }) + return request<{ deleted: true }>({ + method: 'DELETE', + url: `/api/mcp/servers/${encodeURIComponent(name)}`, + }) } export function loginMcpServer(name: string, scopes?: string[]) { - return wsRequest<{ loggedIn: true }>('mcp.servers.login', { - name, - ...(scopes && scopes.length > 0 ? { scopes } : {}), + return request<{ loggedIn: true }>({ + method: 'POST', + url: `/api/mcp/servers/${encodeURIComponent(name)}/login`, + data: scopes && scopes.length > 0 ? { scopes } : {}, }) } export function logoutMcpServer(name: string) { - return wsRequest<{ loggedOut: true }>('mcp.servers.logout', { name }) + return request<{ loggedOut: true }>({ + method: 'POST', + url: `/api/mcp/servers/${encodeURIComponent(name)}/logout`, + data: {}, + }) } export function setActiveMcpServers(names: string[]) { - return wsRequest<{ active: string[] }>('mcp.active.set', { names }) + return request<{ active: string[] }>({ + method: 'POST', + url: '/api/mcp/active', + data: { names }, + }) } diff --git a/packages/web-ui/src/api/request.ts b/packages/web-ui/src/api/request.ts index 5ace0ed..f5927fc 100644 --- a/packages/web-ui/src/api/request.ts +++ b/packages/web-ui/src/api/request.ts @@ -15,7 +15,6 @@ type RetryConfig = InternalAxiosRequestConfig & { } let tokenState: TokenState | null = null -let refreshingPromise: Promise | null = null const baseURL = (import.meta.env?.VITE_SERVER_BASE_URL as string | undefined) ?? undefined @@ -24,11 +23,6 @@ const httpClient: AxiosInstance = axios.create({ timeout: DEFAULT_TIMEOUT, }) -const refreshClient: AxiosInstance = axios.create({ - baseURL, - timeout: DEFAULT_TIMEOUT, -}) - function unwrapEnvelope(payload: unknown): T { const maybeEnvelope = payload as ApiEnvelope if (maybeEnvelope && typeof maybeEnvelope === 'object' && 'success' in maybeEnvelope) { @@ -65,47 +59,6 @@ function parseApiError(error: AxiosError): Error { return new Error('Request failed') } -function normalizeTokens(value: Partial | Partial): TokenState | null { - if (typeof value.accessToken !== 'string' || typeof value.refreshToken !== 'string') { - return null - } - - const accessToken = value.accessToken.trim() - const refreshToken = value.refreshToken.trim() - - if (!accessToken || !refreshToken) return null - - const input = value as Partial & Partial - const now = Date.now() - - const accessTokenExpiresAt = - typeof input.accessTokenExpiresAt === 'number' && - Number.isFinite(input.accessTokenExpiresAt) - ? input.accessTokenExpiresAt - : typeof input.accessTokenExpiresIn === 'number' && - Number.isFinite(input.accessTokenExpiresIn) && - input.accessTokenExpiresIn > 0 - ? now + Math.floor(input.accessTokenExpiresIn * 1000) - : parseJwtExpiresAt(accessToken) - - const refreshTokenExpiresAt = - typeof input.refreshTokenExpiresAt === 'number' && - Number.isFinite(input.refreshTokenExpiresAt) - ? input.refreshTokenExpiresAt - : typeof input.refreshTokenExpiresIn === 'number' && - Number.isFinite(input.refreshTokenExpiresIn) && - input.refreshTokenExpiresIn > 0 - ? now + Math.floor(input.refreshTokenExpiresIn * 1000) - : parseJwtExpiresAt(refreshToken) - - return { - accessToken, - refreshToken, - ...(typeof accessTokenExpiresAt === 'number' ? { accessTokenExpiresAt } : {}), - ...(typeof refreshTokenExpiresAt === 'number' ? { refreshTokenExpiresAt } : {}), - } -} - function parseJwtExpiresAt(token: string): number | undefined { const parts = token.split('.') if (parts.length < 2) return undefined @@ -128,14 +81,40 @@ function base64UrlDecode(raw: string): string { return atob(padded) } -function isAccessTokenNearExpiry( - tokens: TokenState | null, - bufferMs = ACCESS_TOKEN_REFRESH_BUFFER_MS, -) { +function normalizeTokens(value: Partial | Partial): TokenState | null { + if (typeof value.accessToken !== 'string') { + return null + } + + const accessToken = value.accessToken.trim() + if (!accessToken) return null + + const expiresIn = + typeof (value as Partial).expiresIn === 'number' && + Number.isFinite((value as Partial).expiresIn) + ? Math.max(0, Math.floor((value as Partial).expiresIn as number)) + : undefined + + const accessTokenExpiresAt = + 'accessTokenExpiresAt' in value && + typeof value.accessTokenExpiresAt === 'number' && + Number.isFinite(value.accessTokenExpiresAt) + ? value.accessTokenExpiresAt + : typeof expiresIn === 'number' + ? Date.now() + expiresIn * 1000 + : parseJwtExpiresAt(accessToken) + + return { + accessToken, + ...(typeof accessTokenExpiresAt === 'number' ? { accessTokenExpiresAt } : {}), + } +} + +function isAccessTokenNearExpiry(tokens: TokenState | null): boolean { if (!tokens?.accessToken) return false const expiresAt = tokens.accessTokenExpiresAt ?? parseJwtExpiresAt(tokens.accessToken) if (typeof expiresAt !== 'number') return false - return Date.now() + bufferMs >= expiresAt + return Date.now() + ACCESS_TOKEN_REFRESH_BUFFER_MS >= expiresAt } export function getAuthTokens(): TokenState | null { @@ -165,41 +144,9 @@ export function clearAuthTokens(): void { setAuthTokens(null) } -async function refreshTokens(): Promise { - if (refreshingPromise) return refreshingPromise - - const current = getAuthTokens() - if (!current?.refreshToken) return null - - refreshingPromise = (async () => { - try { - const response = await refreshClient.post>( - '/api/auth/refresh', - { - refreshToken: current.refreshToken, - }, - ) - const payload = unwrapEnvelope(response.data) - const tokens = normalizeTokens(payload) - if (!tokens) { - clearAuthTokens() - return null - } - setAuthTokens(tokens) - return tokens - } catch { - clearAuthTokens() - return null - } finally { - refreshingPromise = null - } - })() - - return refreshingPromise -} - export async function refreshAuthTokens(): Promise { - return refreshTokens() + // Core HTTP server does not provide refresh tokens. + return null } export async function ensureValidAccessToken(): Promise { @@ -207,17 +154,19 @@ export async function ensureValidAccessToken(): Promise { if (!current?.accessToken) return null if (isAccessTokenNearExpiry(current)) { - const refreshed = await refreshTokens() - if (refreshed) return refreshed + // Without refresh tokens we can only rely on re-login once the token expires. + const expiresAt = current.accessTokenExpiresAt ?? parseJwtExpiresAt(current.accessToken) + if (typeof expiresAt === 'number' && Date.now() >= expiresAt) { + clearAuthTokens() + return null + } } - return getAuthTokens() + return current } httpClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { - const isRefreshRequest = - typeof config.url === 'string' && config.url.includes('/api/auth/refresh') - const tokens = isRefreshRequest ? getAuthTokens() : await ensureValidAccessToken() + const tokens = await ensureValidAccessToken() if (tokens?.accessToken) { config.headers = config.headers ?? {} config.headers.Authorization = `Bearer ${tokens.accessToken}` @@ -230,16 +179,10 @@ httpClient.interceptors.response.use( async (error: AxiosError) => { const status = error.response?.status const config = error.config as RetryConfig | undefined - const isRefreshRequest = config?.url?.includes('/api/auth/refresh') - if (status === 401 && config && !config.__retried && !isRefreshRequest) { + if (status === 401 && config && !config.__retried) { config.__retried = true - const refreshed = await refreshTokens() - if (refreshed?.accessToken) { - config.headers = config.headers ?? {} - config.headers.Authorization = `Bearer ${refreshed.accessToken}` - return httpClient.request(config) - } + clearAuthTokens() } return Promise.reject(parseApiError(error)) diff --git a/packages/web-ui/src/api/sessions.ts b/packages/web-ui/src/api/sessions.ts index 6e8fd64..337bc3b 100644 --- a/packages/web-ui/src/api/sessions.ts +++ b/packages/web-ui/src/api/sessions.ts @@ -1,4 +1,4 @@ -import { wsRequest } from '@/api/ws-client' +import { request } from '@/api/request' import type { ListSessionsQuery, SessionDetail, @@ -7,17 +7,46 @@ import type { } from '@/api/types' export function getSessions(params: ListSessionsQuery) { - return wsRequest('sessions.list', params) + const { page, pageSize, sortBy, order, project, workspaceCwd, dateFrom, dateTo, q } = params + + return request({ + method: 'GET', + url: '/api/sessions', + params: { + page, + pageSize, + sortBy, + order, + project, + workspaceCwd, + dateFrom, + dateTo, + q, + }, + }) } export function getSessionDetail(sessionId: string) { - return wsRequest('sessions.detail', { sessionId }) + return request({ + method: 'GET', + url: `/api/sessions/${encodeURIComponent(sessionId)}`, + }) } export function getSessionEvents(params: { sessionId: string; cursor?: string; limit?: number }) { - return wsRequest('sessions.events', params) + return request({ + method: 'GET', + url: `/api/sessions/${encodeURIComponent(params.sessionId)}/events`, + params: { + cursor: params.cursor, + limit: params.limit, + }, + }) } export function removeSession(sessionId: string) { - return wsRequest<{ deleted: boolean }>('sessions.remove', { sessionId }) + return request<{ deleted: boolean }>({ + method: 'DELETE', + url: `/api/sessions/${encodeURIComponent(sessionId)}`, + }) } diff --git a/packages/web-ui/src/api/skills.ts b/packages/web-ui/src/api/skills.ts index cc651ae..deb1add 100644 --- a/packages/web-ui/src/api/skills.ts +++ b/packages/web-ui/src/api/skills.ts @@ -1,26 +1,37 @@ -import { wsRequest } from '@/api/ws-client' +import { request } from '@/api/request' import type { SkillDetail, SkillRecord } from '@/api/types' export function getSkills(params?: { scope?: 'project' | 'global' q?: string - workspaceId?: string + workspaceCwd?: string }) { - return wsRequest<{ items: SkillRecord[] }>('skills.list', params ?? {}) + return request<{ items: SkillRecord[] }>({ + method: 'GET', + url: '/api/skills', + params: params, + }) } export function getSkill(id: string) { - return wsRequest('skills.get', { id }) + return request({ + method: 'GET', + url: `/api/skills/${encodeURIComponent(id)}`, + }) } export function createSkill(params: { scope: 'project' | 'global' - workspaceId?: string + workspaceCwd?: string name: string description?: string content?: string }) { - return wsRequest<{ created: true; item: SkillRecord }>('skills.create', params) + return request<{ created: true; item: SkillRecord }>({ + method: 'POST', + url: '/api/skills', + data: params, + }) } export function updateSkill( @@ -30,16 +41,24 @@ export function updateSkill( content?: string }, ) { - return wsRequest<{ updated: true }>('skills.update', { - id, - ...params, + return request<{ updated: true }>({ + method: 'PATCH', + url: `/api/skills/${encodeURIComponent(id)}`, + data: params, }) } export function removeSkill(id: string) { - return wsRequest<{ deleted: true }>('skills.remove', { id }) + return request<{ deleted: true }>({ + method: 'DELETE', + url: `/api/skills/${encodeURIComponent(id)}`, + }) } export function setActiveSkillIds(ids: string[]) { - return wsRequest<{ active: string[] }>('skills.active.set', { ids }) + return request<{ active: string[] }>({ + method: 'POST', + url: '/api/skills/active', + data: { ids }, + }) } diff --git a/packages/web-ui/src/api/types.ts b/packages/web-ui/src/api/types.ts index a2bb225..11a3268 100644 --- a/packages/web-ui/src/api/types.ts +++ b/packages/web-ui/src/api/types.ts @@ -1,5 +1,6 @@ import type { ApiEnvelope as CoreApiEnvelope, + SseEventEnvelope as CoreSseEventEnvelope, FileSuggestion as CoreFileSuggestion, LiveSessionState as CoreLiveSessionState, McpServerRecord as CoreMcpServerRecord, @@ -20,7 +21,6 @@ import type { WorkspaceDirEntry as CoreWorkspaceDirEntry, WorkspaceFsListResult as CoreWorkspaceFsListResult, WorkspaceRecord as CoreWorkspaceRecord, - WsServerEvent as CoreWsServerEvent, } from '@memo-code/core' export type ApiMeta = { @@ -37,20 +37,17 @@ export type ApiError = { export type ApiEnvelope = CoreApiEnvelope export type AuthTokenPair = { - tokenType: 'Bearer' accessToken: string - refreshToken: string - accessTokenExpiresIn: number - refreshTokenExpiresIn: number + expiresIn: number } export type TokenState = { accessToken: string - refreshToken: string accessTokenExpiresAt?: number - refreshTokenExpiresAt?: number } +export type SseEventEnvelope = CoreSseEventEnvelope + export type TokenUsageSummary = CoreTokenUsageSummary export type ToolUsageSummary = CoreToolUsageSummary export type SessionRuntimeStatus = CoreSessionRuntimeStatus @@ -79,7 +76,7 @@ export type ListSessionsQuery = { sortBy?: 'updatedAt' | 'startedAt' | 'project' | 'title' order?: 'asc' | 'desc' project?: string - workspaceId?: string + workspaceCwd?: string dateFrom?: string dateTo?: string q?: string @@ -115,48 +112,10 @@ export type ChatFileSuggestionResponse = { export type SessionInputResult = { accepted: boolean - kind: 'turn' | 'command' - status: 'ok' | 'error' | 'cancelled' - message?: string -} - -export type WsRpcResponse = - | { - id: string - type: 'rpc.response' - ok: true - data: T - } - | { - id: string - type: 'rpc.response' - ok: false - error: { - code: string - message: string - details?: unknown - } - } - -export type WsEventFrame = { - type: 'event' - topic: string - data: T - seq: number - ts: string + queueId: string + queued: number } -export type WsServerEvent = - | CoreWsServerEvent - | { - type: 'runtime.status' - payload: SessionRuntimeBadge - } - | { - type: 'workspace.changed' - payload: unknown - } - export type McpServerConfig = Record export type SkillDetail = { diff --git a/packages/web-ui/src/api/workspaces.ts b/packages/web-ui/src/api/workspaces.ts index bf8d82e..a324ce4 100644 --- a/packages/web-ui/src/api/workspaces.ts +++ b/packages/web-ui/src/api/workspaces.ts @@ -1,22 +1,40 @@ -import { wsRequest } from '@/api/ws-client' +import { request } from '@/api/request' import type { WorkspaceFsListResult, WorkspaceRecord } from '@/api/types' export function listWorkspaces() { - return wsRequest<{ items: WorkspaceRecord[] }>('workspace.list', {}) + return request<{ items: WorkspaceRecord[] }>({ + method: 'GET', + url: '/api/workspaces', + }) } export function addWorkspace(params: { cwd: string; name?: string }) { - return wsRequest<{ created: boolean; item: WorkspaceRecord }>('workspace.add', params) + return request<{ created: boolean; item: WorkspaceRecord }>({ + method: 'POST', + url: '/api/workspaces', + data: params, + }) } export function updateWorkspace(params: { workspaceId: string; name: string }) { - return wsRequest<{ updated: boolean; item: WorkspaceRecord }>('workspace.update', params) + return request<{ updated: boolean; item: WorkspaceRecord }>({ + method: 'PATCH', + url: `/api/workspaces/${encodeURIComponent(params.workspaceId)}`, + data: { name: params.name }, + }) } export function removeWorkspace(params: { workspaceId: string }) { - return wsRequest<{ deleted: boolean; deletedSessions?: number }>('workspace.remove', params) + return request<{ deleted: boolean; deletedSessions?: number }>({ + method: 'DELETE', + url: `/api/workspaces/${encodeURIComponent(params.workspaceId)}`, + }) } export function listWorkspaceDirectories(path?: string) { - return wsRequest('workspace.fs.list', path ? { path } : {}) + return request({ + method: 'GET', + url: '/api/workspaces/fs/list', + params: path ? { path } : undefined, + }) } diff --git a/packages/web-ui/src/api/ws-client.ts b/packages/web-ui/src/api/ws-client.ts deleted file mode 100644 index 2f4192f..0000000 --- a/packages/web-ui/src/api/ws-client.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { - clearAuthTokens, - ensureValidAccessToken, - getAuthTokens, - refreshAuthTokens, -} from '@/api/request' - -type RpcRequestFrame = { - id: string - type: 'rpc.request' - method: string - params?: unknown -} - -type RpcResponseFrame = - | { - id: string - type: 'rpc.response' - ok: true - data: unknown - } - | { - id: string - type: 'rpc.response' - ok: false - error: { - code: string - message: string - details?: unknown - } - } - -type EventFrame = { - type: 'event' - topic: string - data: unknown - seq: number - ts: string -} - -type PendingRequest = { - frame: RpcRequestFrame - resolve: (value: unknown) => void - reject: (reason?: unknown) => void - timeoutId: number | null -} - -type TopicHandler = (data: unknown, frame: EventFrame) => void - -type ReconnectHook = () => void | Promise -type WsRequestOptions = { - timeoutMs?: number | null -} - -const DEFAULT_TIMEOUT_MS = 20_000 -const WS_UNAUTHORIZED_CLOSE = 4401 - -function resolveWsBaseUrl(): string { - const configured = import.meta.env?.VITE_SERVER_BASE_URL as string | undefined - if (!configured) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - return `${protocol}//${window.location.host}` - } - - const url = new URL(configured, window.location.origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - return url.origin -} - -function parseCloseReason(code: number): string { - if (code === WS_UNAUTHORIZED_CLOSE) return 'Unauthorized websocket session' - if (code === 4404) return 'Session not found' - if (code === 4409) return 'Session is occupied by another client' - return 'WebSocket disconnected' -} - -class MemoWsClient { - private socket: WebSocket | null = null - private connecting: Promise | null = null - private reconnectTimer: number | null = null - private reconnectDelayMs = 1000 - private closedByUser = false - - private readonly pending = new Map() - private readonly topicHandlers = new Map>() - private readonly reconnectHooks = new Set() - - async request( - method: string, - params?: unknown, - options?: WsRequestOptions, - ): Promise { - await this.ensureConnected() - - const id = crypto.randomUUID() - const frame: RpcRequestFrame = { - id, - type: 'rpc.request', - method, - params, - } - const timeoutMs = options?.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs - - return new Promise((resolve, reject) => { - const timeoutId = - typeof timeoutMs === 'number' && timeoutMs > 0 - ? window.setTimeout(() => { - this.pending.delete(id) - reject(new Error(`WS RPC timeout: ${method}`)) - }, timeoutMs) - : null - - this.pending.set(id, { - frame, - timeoutId, - resolve: (value) => resolve(value as T), - reject, - }) - - try { - this.sendRaw(frame) - } catch (error) { - if (timeoutId !== null) window.clearTimeout(timeoutId) - this.pending.delete(id) - reject(error) - } - }) - } - - subscribe(topic: string, handler: TopicHandler): () => void { - let set = this.topicHandlers.get(topic) - if (!set) { - set = new Set() - this.topicHandlers.set(topic, set) - } - set.add(handler) - - void this.ensureConnected().catch(() => { - // Connection will retry on demand. - }) - - return () => { - const target = this.topicHandlers.get(topic) - if (!target) return - target.delete(handler) - if (target.size === 0) { - this.topicHandlers.delete(topic) - } - } - } - - onReconnect(hook: ReconnectHook): () => void { - this.reconnectHooks.add(hook) - return () => { - this.reconnectHooks.delete(hook) - } - } - - disconnect(): void { - this.closedByUser = true - if (this.reconnectTimer !== null) { - window.clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - if (this.socket) { - this.socket.close(1000, 'manual disconnect') - this.socket = null - } - this.rejectAllPending('WebSocket disconnected') - } - - private async ensureConnected(): Promise { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - return - } - if (this.connecting) { - return this.connecting - } - this.closedByUser = false - - this.connecting = this.connect() - try { - await this.connecting - } finally { - this.connecting = null - } - } - - private async connect(): Promise { - const tokens = (await ensureValidAccessToken()) ?? getAuthTokens() - const accessToken = tokens?.accessToken?.trim() - if (!accessToken) { - throw new Error('Missing access token') - } - - const base = resolveWsBaseUrl() - const wsUrl = `${base}/api/ws?accessToken=${encodeURIComponent(accessToken)}` - - await new Promise((resolve, reject) => { - const socket = new WebSocket(wsUrl) - let opened = false - - socket.onopen = () => { - opened = true - this.socket = socket - this.reconnectDelayMs = 1000 - resolve() - void this.runReconnectHooks() - } - - socket.onmessage = (event) => { - this.handleMessage(event.data) - } - - socket.onerror = () => { - if (!opened) { - reject(new Error('WebSocket connection failed')) - } - } - - socket.onclose = (event) => { - if (this.socket === socket) { - this.socket = null - } - - const reason = parseCloseReason(event.code) - this.rejectAllPending(reason) - - if (!opened) { - if (event.code === WS_UNAUTHORIZED_CLOSE) { - void this.handleUnauthorizedClose() - } - reject(new Error(reason)) - return - } - - if (this.closedByUser) return - - if (event.code === WS_UNAUTHORIZED_CLOSE) { - void this.handleUnauthorizedClose() - return - } - - this.scheduleReconnect() - } - }) - } - - private async runReconnectHooks(): Promise { - for (const hook of this.reconnectHooks) { - try { - await hook() - } catch { - // Avoid breaking connection on hook failures. - } - } - } - - private async handleUnauthorizedClose(): Promise { - try { - const refreshed = await refreshAuthTokens() - if (!refreshed?.accessToken) { - clearAuthTokens() - return - } - this.scheduleReconnect(0) - } catch { - clearAuthTokens() - } - } - - private scheduleReconnect(delayMs?: number): void { - if (this.closedByUser) return - if (this.reconnectTimer !== null) return - - const waitMs = typeof delayMs === 'number' ? delayMs : this.reconnectDelayMs - - this.reconnectTimer = window.setTimeout(() => { - this.reconnectTimer = null - void this.ensureConnected().catch(() => { - this.reconnectDelayMs = Math.min(10_000, Math.floor(this.reconnectDelayMs * 1.8)) - this.scheduleReconnect() - }) - }, waitMs) - } - - private handleMessage(raw: unknown): void { - let parsed: RpcResponseFrame | EventFrame - try { - parsed = JSON.parse(String(raw)) as RpcResponseFrame | EventFrame - } catch { - return - } - - if (parsed.type === 'rpc.response') { - const pending = this.pending.get(parsed.id) - if (!pending) return - - this.pending.delete(parsed.id) - if (pending.timeoutId !== null) window.clearTimeout(pending.timeoutId) - - if (parsed.ok) { - pending.resolve(parsed.data) - return - } - - const message = parsed.error.message || 'WS RPC failed' - const error = new Error(message) - ;(error as Error & { code?: string; details?: unknown }).code = parsed.error.code - ;(error as Error & { code?: string; details?: unknown }).details = parsed.error.details - pending.reject(error) - return - } - - if (parsed.type === 'event') { - this.emitTopic(parsed.topic, parsed.data, parsed) - } - } - - private emitTopic(topic: string, data: unknown, frame: EventFrame): void { - const exact = this.topicHandlers.get(topic) - if (exact) { - for (const handler of exact) { - handler(data, frame) - } - } - - const wildcard = this.topicHandlers.get('*') - if (wildcard) { - for (const handler of wildcard) { - handler(data, frame) - } - } - } - - private sendRaw(frame: RpcRequestFrame): void { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket is not connected') - } - this.socket.send(JSON.stringify(frame)) - } - - private rejectAllPending(message: string): void { - for (const [id, pending] of this.pending.entries()) { - if (pending.timeoutId !== null) window.clearTimeout(pending.timeoutId) - pending.reject(new Error(message)) - this.pending.delete(id) - } - } -} - -const client = new MemoWsClient() - -export function wsRequest( - method: string, - params?: unknown, - options?: WsRequestOptions, -): Promise { - return client.request(method, params, options) -} - -export function wsSubscribe(topic: string, handler: TopicHandler): () => void { - return client.subscribe(topic, handler) -} - -export function onWsReconnect(hook: ReconnectHook): () => void { - return client.onReconnect(hook) -} - -export function disconnectWs(): void { - client.disconnect() -} diff --git a/packages/web-ui/src/layouts/app-layout.tsx b/packages/web-ui/src/layouts/app-layout.tsx index 5fa0bef..9b136a7 100644 --- a/packages/web-ui/src/layouts/app-layout.tsx +++ b/packages/web-ui/src/layouts/app-layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Menu } from 'lucide-react' import { Outlet, useLocation } from 'react-router-dom' -import { chatApi, sessionsApi, wsSubscribe } from '@/api' +import { chatApi, sessionsApi } from '@/api' import { MemoLogo } from '@/components/layout/memo-logo' import { Sidebar } from '@/components/layout/sidebar' import { Button } from '@/components/ui/button' @@ -44,16 +44,6 @@ export function AppLayout() { } }, []) - const createSessionForWorkspace = useCallback( - async (workspaceId: string) => { - const sessionId = await createSession(workspaceId) - if (!sessionId) return null - await loadSessions() - return sessionId - }, - [createSession, loadSessions], - ) - const loadRuntimeBadges = useCallback(async () => { try { const response = await chatApi.listChatRuntimes({}) @@ -63,6 +53,16 @@ export function AppLayout() { } }, [setRuntimeBadges]) + const createSessionForWorkspace = useCallback( + async (workspaceId: string) => { + const sessionId = await createSession(workspaceId) + if (!sessionId) return null + await Promise.all([loadSessions(), loadRuntimeBadges()]) + return sessionId + }, + [createSession, loadRuntimeBadges, loadSessions], + ) + const bootstrap = useCallback(async () => { await Promise.all([loadWorkspaces(), loadSessions(), loadRuntimeBadges()]) }, [loadRuntimeBadges, loadSessions, loadWorkspaces]) @@ -78,20 +78,14 @@ export function AppLayout() { }, [bootstrap]) useEffect(() => { - const unsubs = [ - wsSubscribe('workspace.changed', () => { - void Promise.all([loadWorkspaces(), loadSessions()]) - }), - wsSubscribe('chat.runtime.status', () => { - void loadSessions() - }), - ] + const timer = window.setInterval(() => { + void Promise.all([loadSessions(), loadRuntimeBadges()]) + }, 5000) + return () => { - for (const unsub of unsubs) { - unsub() - } + window.clearInterval(timer) } - }, [loadSessions, loadWorkspaces]) + }, [loadRuntimeBadges, loadSessions]) const workspaceById = useMemo(() => { const map = new Map() diff --git a/packages/web-ui/src/pages/chat/components/chat-input-panel.tsx b/packages/web-ui/src/pages/chat/components/chat-input-panel.tsx index 534e4ff..7de8606 100644 --- a/packages/web-ui/src/pages/chat/components/chat-input-panel.tsx +++ b/packages/web-ui/src/pages/chat/components/chat-input-panel.tsx @@ -88,7 +88,7 @@ type ChatInputPanelProps = { onCancelTurn: () => Promise | void onApprovalDecision: (decision: ApprovalDecision) => Promise | void sessionId?: string | null - workspaceId?: string | null + workspaceCwd?: string | null contextPercent: number queuedInputs: QueuedInputItem[] onEditQueuedInput: (item: QueuedInputItem) => Promise | void @@ -111,7 +111,7 @@ export function ChatInputPanel({ onCancelTurn, onApprovalDecision, sessionId, - workspaceId, + workspaceCwd, contextPercent, queuedInputs, onEditQueuedInput, @@ -148,7 +148,7 @@ export function ChatInputPanel({ } const generation = ++requestIdRef.current - if (!activeTrigger || !hasActiveSession || (!sessionId && !workspaceId)) { + if (!activeTrigger || !hasActiveSession || (!sessionId && !workspaceCwd)) { setFileSuggestions([]) setActiveSuggestionIndex(0) setLoadingFileSuggestions(false) @@ -162,7 +162,7 @@ export function ChatInputPanel({ .suggestChatFiles({ query: activeTrigger.query, sessionId: sessionId?.trim() || undefined, - workspaceId: sessionId?.trim() ? undefined : workspaceId?.trim() || undefined, + workspaceCwd: sessionId?.trim() ? undefined : workspaceCwd?.trim() || undefined, limit: 8, }) .then((result) => { @@ -189,7 +189,7 @@ export function ChatInputPanel({ requestTimerRef.current = null } } - }, [activeTrigger, hasActiveSession, sessionId, workspaceId]) + }, [activeTrigger, hasActiveSession, sessionId, workspaceCwd]) function applyFileSuggestion(item: FileSuggestion) { const trigger = activeTrigger diff --git a/packages/web-ui/src/pages/chat/components/chat-timeline.tsx b/packages/web-ui/src/pages/chat/components/chat-timeline.tsx index 284013f..7d3ebe8 100644 --- a/packages/web-ui/src/pages/chat/components/chat-timeline.tsx +++ b/packages/web-ui/src/pages/chat/components/chat-timeline.tsx @@ -25,7 +25,7 @@ export function ChatTimeline({ sessionCwd, messagesEndRef, }: ChatTimelineProps) { - if (!hasActiveSession) { + if (!hasActiveSession && turns.length === 0) { return (
diff --git a/packages/web-ui/src/pages/chat/components/markdown-message.tsx b/packages/web-ui/src/pages/chat/components/markdown-message.tsx index 06633a0..488c86e 100644 --- a/packages/web-ui/src/pages/chat/components/markdown-message.tsx +++ b/packages/web-ui/src/pages/chat/components/markdown-message.tsx @@ -1,6 +1,5 @@ import { cjk } from '@streamdown/cjk' import { code } from '@streamdown/code' -import { mermaid } from '@streamdown/mermaid' import { Streamdown } from 'streamdown' import { cn } from '@/lib/utils' @@ -12,7 +11,6 @@ type MarkdownMessageProps = { const streamdownPlugins = { code, - mermaid, cjk, } diff --git a/packages/web-ui/src/pages/chat/index.tsx b/packages/web-ui/src/pages/chat/index.tsx index b0526e6..8eea8b4 100644 --- a/packages/web-ui/src/pages/chat/index.tsx +++ b/packages/web-ui/src/pages/chat/index.tsx @@ -198,7 +198,7 @@ export function ChatPage() { onCancelTurn={cancelCurrentTurn} onApprovalDecision={handleApprovalDecision} sessionId={liveSession?.id ?? selectedSessionId} - workspaceId={liveSession?.workspaceId ?? layout.selectedWorkspaceId} + workspaceCwd={liveSession?.cwd ?? layout.selectedWorkspace?.cwd ?? null} contextPercent={contextPercent} queuedInputs={liveSession?.queuedInputs ?? []} onEditQueuedInput={handleEditQueuedInput} diff --git a/packages/web-ui/src/pages/login-page.tsx b/packages/web-ui/src/pages/login-page.tsx index ef1fcf6..986c7fb 100644 --- a/packages/web-ui/src/pages/login-page.tsx +++ b/packages/web-ui/src/pages/login-page.tsx @@ -24,7 +24,6 @@ export function LoginPage() { const login = useAuthStore((state) => state.login) const clearError = useAuthStore((state) => state.clearError) - const [username, setUsername] = useState('') const [password, setPassword] = useState('') useEffect(() => { @@ -42,7 +41,7 @@ export function LoginPage() { async function handleSubmit(event: FormEvent) { event.preventDefault() clearError() - const ok = await login(username, password) + const ok = await login(password) if (ok) { const state = location.state as RedirectState | null const target = state?.from?.pathname || '/chat' @@ -64,22 +63,11 @@ export function LoginPage() { - Account Login - Use your local server credentials. + Password Login + Use the shared server password.
-
- - setUsername(e.target.value)} - placeholder="Enter your username" - /> -
- Refresh: - - {maskedToken(tokens.refreshToken)} - + + Refresh tokens are not enabled. +
) : ( diff --git a/packages/web-ui/src/pages/skills-page.tsx b/packages/web-ui/src/pages/skills-page.tsx index f482b44..a2b2ef6 100644 --- a/packages/web-ui/src/pages/skills-page.tsx +++ b/packages/web-ui/src/pages/skills-page.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { cjk } from '@streamdown/cjk' import { code } from '@streamdown/code' -import { mermaid } from '@streamdown/mermaid' import { Streamdown } from 'streamdown' import { FileText, Folder, Globe, Loader2, Trash2, X, Zap } from 'lucide-react' import type { SkillDetail, SkillRecord } from '@/api/types' @@ -17,7 +16,6 @@ import { useSkillsStore } from '@/stores' const streamdownPlugins = { code, - mermaid, cjk, } diff --git a/packages/web-ui/src/stores/auth-store.ts b/packages/web-ui/src/stores/auth-store.ts index 78ab757..e76ddea 100644 --- a/packages/web-ui/src/stores/auth-store.ts +++ b/packages/web-ui/src/stores/auth-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { authApi, clearAuthTokens, disconnectWs, getAuthTokens, setAuthTokens } from '@/api' +import { authApi, clearAuthTokens, getAuthTokens, setAuthTokens } from '@/api' import { getErrorMessage } from '@/utils/error' type AuthStore = { @@ -7,7 +7,7 @@ type AuthStore = { username: string pending: boolean error: string | null - login: (username: string, password: string) => Promise + login: (password: string) => Promise logout: () => Promise clearError: () => void } @@ -17,17 +17,15 @@ export const useAuthStore = create((set) => ({ username: 'memo', pending: false, error: null, - async login(username, password) { + async login(password) { set({ pending: true, error: null }) try { - const pair = await authApi.login({ username, password }) + const pair = await authApi.login({ password }) setAuthTokens({ accessToken: pair.accessToken, - refreshToken: pair.refreshToken, - accessTokenExpiresAt: Date.now() + pair.accessTokenExpiresIn * 1000, - refreshTokenExpiresAt: Date.now() + pair.refreshTokenExpiresIn * 1000, + accessTokenExpiresAt: Date.now() + pair.expiresIn * 1000, }) - set({ isAuthenticated: true, username, pending: false }) + set({ isAuthenticated: true, username: 'memo', pending: false }) return true } catch (error) { set({ pending: false, error: getErrorMessage(error, 'Login failed') }) @@ -35,16 +33,12 @@ export const useAuthStore = create((set) => ({ } }, async logout() { - const current = getAuthTokens() set({ pending: true, error: null }) try { - if (current?.refreshToken) { - await authApi.logout({ refreshToken: current.refreshToken }) - } + await authApi.logout() } catch { // Ignore logout API failure and clear local state anyway. } finally { - disconnectWs() clearAuthTokens() set({ isAuthenticated: false, pending: false }) } diff --git a/packages/web-ui/src/stores/chat-store.ts b/packages/web-ui/src/stores/chat-store.ts index e9bd309..e2de33a 100644 --- a/packages/web-ui/src/stores/chat-store.ts +++ b/packages/web-ui/src/stores/chat-store.ts @@ -1,12 +1,15 @@ import { create } from 'zustand' -import { chatApi, onWsReconnect, wsSubscribe } from '@/api' +import { chatApi, getAuthTokens, sessionsApi } from '@/api' +import { ensureValidAccessToken } from '@/api/request' import type { ChatTurn, LiveSessionState, + SessionDetail, SessionInputResult, SessionRuntimeBadge, SessionTurnStep, } from '@/api/types' +import { useWorkspaceStore } from '@/stores/workspace-store' import { calculateContextPercent } from '@/utils/context' import { getErrorMessage } from '@/utils/error' @@ -60,8 +63,15 @@ type ToolObservationPayload = { resultStatus?: string } -let subscriptionsInitialized = false +type StoreSetter = ( + updater: Partial | ((state: ChatStore) => Partial), +) => void + let activeSessionId: string | null = null +let streamAbortController: AbortController | null = null +let streamReconnectTimer: number | null = null +let streamRetryDelayMs = 1000 +let streamNonce = 0 function normalizeAction(value: unknown): ToolAction | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined @@ -137,6 +147,19 @@ function normalizeTurns(raw: unknown): ChatTurn[] { return turns } +function toTurnsFromSessionDetail(detail: SessionDetail): ChatTurn[] { + const turns = detail.turns.map((turn) => ({ + turn: turn.turn, + input: turn.input ?? '', + assistant: turn.finalText ?? '', + status: turn.status ?? 'ok', + errorMessage: turn.errorMessage, + steps: normalizeSteps(turn.steps), + })) + turns.sort((a, b) => a.turn - b.turn) + return turns +} + function createEmptyTurn(turn: number): ChatTurn { return { turn, @@ -287,68 +310,140 @@ function normalizePercent(value: unknown): number | undefined { return undefined } -function ensureSubscriptions( - set: (updater: Partial | ((state: ChatStore) => Partial)) => void, -): void { - if (subscriptionsInitialized) return - subscriptionsInitialized = true +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} - const asRecord = (value: unknown): Record | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - return value as Record +function isNotFoundError(error: unknown): boolean { + const message = getErrorMessage(error, '').toLowerCase() + if (!message) return false + return ( + message.includes('session not found') || + message.includes('route not found') || + message.includes('not_found') || + message.includes(' 404') + ) +} + +function resolveApiBaseUrl(): string { + const configured = import.meta.env?.VITE_SERVER_BASE_URL as string | undefined + if (!configured) return window.location.origin + + const parsed = new URL(configured, window.location.origin) + const pathname = parsed.pathname.replace(/\/+$/g, '') + return `${parsed.origin}${pathname}` +} + +function buildApiUrl(pathname: string): string { + const base = resolveApiBaseUrl().replace(/\/+$/g, '') + const path = pathname.startsWith('/') ? pathname : `/${pathname}` + return `${base}${path}` +} + +function clearReconnectTimer(): void { + if (streamReconnectTimer === null) return + window.clearTimeout(streamReconnectTimer) + streamReconnectTimer = null +} + +function stopSessionStream(): void { + clearReconnectTimer() + if (streamAbortController) { + streamAbortController.abort() + streamAbortController = null } +} - wsSubscribe('chat.session.snapshot', (raw) => { - const data = asRecord(raw) - if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string') return - const state = asRecord(data.state) as LiveSessionState | null - if (!state) return - const normalizedCurrentContextTokens = normalizeNonNegativeNumber( - state.currentContextTokens, - ) - const normalizedContextWindow = normalizeNonNegativeNumber(state.contextWindow) - const normalizedContextPercent = calculateContextPercent( - normalizedCurrentContextTokens, - normalizedContextWindow, - ) - const ignored = sessionId !== activeSessionId +function scheduleReconnect(sessionId: string, nonce: number, set: StoreSetter): void { + if (activeSessionId !== sessionId || streamNonce !== nonce) return + if (streamReconnectTimer !== null) return - const badge: SessionRuntimeBadge = { - sessionId, - workspaceId: state.workspaceId, - status: state.status, - updatedAt: new Date().toISOString(), + const waitMs = streamRetryDelayMs + streamReconnectTimer = window.setTimeout(() => { + streamReconnectTimer = null + void openSessionStream(sessionId, nonce, set) + }, waitMs) + streamRetryDelayMs = Math.min(10_000, Math.floor(streamRetryDelayMs * 1.8)) +} + +function parseSseFrame(frame: string): { event: string; data: unknown } | null { + let eventName = '' + const dataChunks: string[] = [] + + for (const line of frame.split('\n')) { + if (!line || line.startsWith(':')) continue + + const separator = line.indexOf(':') + const field = separator >= 0 ? line.slice(0, separator) : line + const value = separator >= 0 ? line.slice(separator + 1).replace(/^ /, '') : '' + + if (field === 'event') { + eventName = value + } else if (field === 'data') { + dataChunks.push(value) } + } - if (ignored) { - set((store) => ({ - runtimeBadges: withRuntimeBadge(store.runtimeBadges, badge), - })) - return + if (dataChunks.length === 0) return null + + const rawData = dataChunks.join('\n') + let parsed: unknown = rawData + try { + parsed = JSON.parse(rawData) as unknown + } catch { + // Keep raw text payload when data is not JSON. + } + + const envelope = asRecord(parsed) + if (envelope) { + const envelopeEvent = typeof envelope.event === 'string' ? envelope.event : eventName + if (!envelopeEvent) return null + return { + event: envelopeEvent, + data: Object.prototype.hasOwnProperty.call(envelope, 'data') ? envelope.data : parsed, } + } - set((store) => ({ - connected: true, - liveSession: state, - turns: normalizeTurns(data.turns), - systemMessages: [], - error: null, - currentContextTokens: normalizedCurrentContextTokens, - contextLimit: normalizedContextWindow, - contextPercent: normalizedContextPercent, - runtimeBadges: withRuntimeBadge(store.runtimeBadges, badge), - })) - }) + if (!eventName) return null + return { + event: eventName, + data: parsed, + } +} + +function consumeSseBuffer( + buffer: string, + onEvent: (eventName: string, data: unknown) => void, +): string { + let normalized = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + while (true) { + const boundary = normalized.indexOf('\n\n') + if (boundary < 0) break + + const frame = normalized.slice(0, boundary) + normalized = normalized.slice(boundary + 2) + const parsed = parseSseFrame(frame) + if (!parsed) continue + onEvent(parsed.event, parsed.data) + } + + return normalized +} + +function applyStreamEvent( + set: StoreSetter, + sessionId: string, + eventName: string, + raw: unknown, +): void { + if (sessionId !== activeSessionId) return + + if (eventName === 'session.snapshot') { + const state = asRecord(raw) as LiveSessionState | null + if (!state || typeof state.id !== 'string') return - wsSubscribe('chat.session.state', (raw) => { - const data = asRecord(raw) - if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string') return - const state = asRecord(data.state) as LiveSessionState | null - if (!state) return const normalizedCurrentContextTokens = normalizeNonNegativeNumber( state.currentContextTokens, ) @@ -357,12 +452,11 @@ function ensureSubscriptions( normalizedCurrentContextTokens, normalizedContextWindow, ) - const ignored = sessionId !== activeSessionId - if (ignored) return set((store) => ({ connected: true, liveSession: state, + error: null, currentContextTokens: normalizedCurrentContextTokens, contextLimit: normalizedContextWindow, contextPercent: normalizedContextPercent, @@ -373,83 +467,48 @@ function ensureSubscriptions( updatedAt: new Date().toISOString(), }), })) - }) + return + } - wsSubscribe('chat.session.status', (raw) => { + if (eventName === 'session.status') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string') return const status = data.status if (status !== 'idle' && status !== 'running' && status !== 'closed') return set((state) => { const workspaceId = - typeof data.workspaceId === 'string' - ? data.workspaceId - : state.liveSession?.id === sessionId - ? state.liveSession.workspaceId - : state.runtimeBadges[sessionId]?.workspaceId + state.liveSession?.workspaceId ?? state.runtimeBadges[sessionId]?.workspaceId if (!workspaceId) return {} return { - liveSession: - state.liveSession?.id === sessionId - ? { - ...state.liveSession, - status, - } - : state.liveSession, + liveSession: state.liveSession + ? { + ...state.liveSession, + status, + } + : state.liveSession, runtimeBadges: withRuntimeBadge(state.runtimeBadges, { sessionId, workspaceId, status, - updatedAt: - typeof data.updatedAt === 'string' - ? data.updatedAt - : new Date().toISOString(), + updatedAt: new Date().toISOString(), }), } }) - }) - - wsSubscribe('chat.runtime.status', (raw) => { - const data = asRecord(raw) - if (!data) return - const sessionId = data.sessionId - const workspaceId = data.workspaceId - const status = data.status - const updatedAt = data.updatedAt - - if (typeof sessionId !== 'string') return - if (typeof workspaceId !== 'string') return - if (status !== 'idle' && status !== 'running' && status !== 'closed') return - if (typeof updatedAt !== 'string') return - - set((state) => ({ - runtimeBadges: withRuntimeBadge(state.runtimeBadges, { - sessionId, - workspaceId, - status, - updatedAt, - }), - })) - }) + return + } - wsSubscribe('chat.turn.start', (raw) => { + if (eventName === 'turn.start') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string') return const turn = data.turn if (typeof turn !== 'number') return - const ignored = sessionId !== activeSessionId const input = typeof data.input === 'string' ? data.input : '' const promptTokens = typeof data.promptTokens === 'number' && Number.isFinite(data.promptTokens) ? Math.max(0, data.promptTokens) : undefined - if (ignored) return set((state) => { const next: Partial = { @@ -461,33 +520,40 @@ function ensureSubscriptions( } return next }) - }) + return + } - wsSubscribe('chat.context.usage', (raw) => { + if (eventName === 'context.usage') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string') return - const ignored = sessionId !== activeSessionId const promptTokens = normalizeNonNegativeNumber(data.promptTokens) const contextWindow = normalizeNonNegativeNumber(data.contextWindow) const usagePercent = normalizePercent(data.usagePercent) ?? calculateContextPercent(promptTokens, contextWindow) - if (ignored) return set({ currentContextTokens: promptTokens, contextLimit: contextWindow, contextPercent: usagePercent, }) - }) + return + } - wsSubscribe('chat.turn.chunk', (raw) => { + if (eventName === 'context.compact') { + const data = asRecord(raw) + if (!data) return + const afterTokens = normalizeNonNegativeNumber(data.afterTokens) + set((state) => ({ + currentContextTokens: afterTokens, + contextPercent: calculateContextPercent(afterTokens, state.contextLimit), + })) + return + } + + if (eventName === 'assistant.chunk') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const turn = data.turn const step = data.step const chunk = data.chunk @@ -498,13 +564,12 @@ function ensureSubscriptions( set((state) => ({ turns: appendTurnChunk(state.turns, turn, step, chunk), })) - }) + return + } - wsSubscribe('chat.tool.action', (raw) => { + if (eventName === 'tool.action') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const turn = data.turn const step = data.step const action = normalizeAction(data.action) @@ -521,13 +586,12 @@ function ensureSubscriptions( thinking, }), })) - }) + return + } - wsSubscribe('chat.tool.observation', (raw) => { + if (eventName === 'tool.observation') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const turn = data.turn const step = data.step const observation = data.observation @@ -554,13 +618,12 @@ function ensureSubscriptions( resultStatus, }), })) - }) + return + } - wsSubscribe('chat.turn.final', (raw) => { + if (eventName === 'turn.final') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const turn = data.turn if (typeof turn !== 'number') return const finalText = typeof data.finalText === 'string' ? data.finalText : '' @@ -575,13 +638,12 @@ function ensureSubscriptions( errorMessage, }), })) - }) + return + } - wsSubscribe('chat.approval.request', (raw) => { + if (eventName === 'approval.request') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const fingerprint = data.fingerprint const toolName = data.toolName @@ -610,13 +672,12 @@ function ensureSubscriptions( } : state.liveSession, })) - }) + return + } - wsSubscribe('chat.system.message', (raw) => { + if (eventName === 'system.message') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return const title = typeof data.title === 'string' ? data.title : 'System' const content = typeof data.content === 'string' ? data.content : '' @@ -624,30 +685,231 @@ function ensureSubscriptions( set((state) => ({ systemMessages: [...state.systemMessages, `${title}: ${content}`], })) - }) + return + } - wsSubscribe('chat.error', (raw) => { + if (eventName === 'error') { const data = asRecord(raw) if (!data) return - const sessionId = data.sessionId - if (typeof sessionId !== 'string' || sessionId !== activeSessionId) return set({ error: typeof data.message === 'string' ? data.message : 'Chat error', }) - }) + } +} + +async function openSessionStream( + sessionId: string, + nonce: number, + set: StoreSetter, +): Promise { + if (activeSessionId !== sessionId || streamNonce !== nonce) return + let shouldReconnect = true + + const tokens = (await ensureValidAccessToken()) ?? getAuthTokens() + const accessToken = tokens?.accessToken?.trim() + if (!accessToken) { + shouldReconnect = false + set({ + connected: false, + error: 'Missing access token', + }) + return + } + if (activeSessionId !== sessionId || streamNonce !== nonce) return + + stopSessionStream() + const abortController = new AbortController() + streamAbortController = abortController - onWsReconnect(async () => { - const currentSessionId = activeSessionId - if (!currentSessionId) return + try { + const response = await fetch( + buildApiUrl(`/api/chat/sessions/${encodeURIComponent(sessionId)}/events`), + { + method: 'GET', + headers: { + Accept: 'text/event-stream', + Authorization: `Bearer ${accessToken}`, + }, + signal: abortController.signal, + }, + ) + + if (response.status === 401) { + shouldReconnect = false + set({ + connected: false, + error: 'Authentication expired. Please login again.', + }) + return + } + if (response.status === 404) { + shouldReconnect = false + set({ + connected: false, + error: 'Session not found', + }) + return + } + + if (!response.ok || !response.body) { + throw new Error(`Failed to connect session stream (${response.status})`) + } + + streamRetryDelayMs = 1000 + set({ connected: true }) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + buffer = consumeSseBuffer(buffer, (eventName, data) => { + applyStreamEvent(set, sessionId, eventName, data) + }) + } + + buffer += decoder.decode() + if (buffer.trim().length > 0) { + consumeSseBuffer(buffer, (eventName, data) => { + applyStreamEvent(set, sessionId, eventName, data) + }) + } + } catch (error) { + if (abortController.signal.aborted) return + set({ connected: false }) + const message = getErrorMessage(error, 'Session stream interrupted') + if (message && !message.includes('Failed to connect session stream')) { + scheduleReconnect(sessionId, nonce, set) + return + } + shouldReconnect = false + set({ error: message }) + } finally { + if (streamAbortController === abortController) { + streamAbortController = null + } + } + + if (shouldReconnect && activeSessionId === sessionId && streamNonce === nonce) { + set({ connected: false }) + scheduleReconnect(sessionId, nonce, set) + } +} + +function startSessionStream(sessionId: string, set: StoreSetter): void { + clearReconnectTimer() + streamNonce += 1 + const nonce = streamNonce + streamRetryDelayMs = 1000 + void openSessionStream(sessionId, nonce, set) +} + +function resolveWorkspaceCwd(workspaceId: string): string | null { + const normalizedId = workspaceId.trim() + if (!normalizedId) return null + + const workspace = useWorkspaceStore.getState().items.find((item) => item.id === normalizedId) + + return workspace?.cwd ?? null +} + +export const useChatStore = create((set, get) => ({ + liveSession: null, + turns: [], + systemMessages: [], + runtimeBadges: {}, + currentContextTokens: 0, + contextLimit: 0, + contextPercent: 0, + loading: false, + connected: false, + error: null, + + clearError() { + set({ error: null }) + }, + + setRuntimeBadges(items) { + const next: Record = {} + for (const item of items) { + next[item.sessionId] = item + } + set({ runtimeBadges: next }) + }, + + async createSession(workspaceId) { + const targetWorkspaceId = workspaceId.trim() + if (!targetWorkspaceId) { + set({ error: 'workspaceId is required' }) + return null + } + + const cwd = resolveWorkspaceCwd(targetWorkspaceId) + if (!cwd) { + set({ error: 'Selected workspace is missing or unavailable' }) + return null + } + + set({ loading: true, error: null }) try { - const snapshot = await chatApi.attachLiveSession(currentSessionId) - if (activeSessionId !== currentSessionId) return + const state = await chatApi.createLiveSession({ + cwd, + }) + const snapshot = await chatApi.attachLiveSession(state.id) + activeSessionId = state.id + startSessionStream(state.id, set) + set((store) => ({ + liveSession: snapshot.state, + turns: normalizeTurns(snapshot.turns), + systemMessages: [], + currentContextTokens: normalizeNonNegativeNumber( + snapshot.state.currentContextTokens, + ), + contextLimit: normalizeNonNegativeNumber(snapshot.state.contextWindow), + contextPercent: calculateContextPercent( + snapshot.state.currentContextTokens ?? 0, + snapshot.state.contextWindow ?? 0, + ), + loading: false, + connected: true, + runtimeBadges: withRuntimeBadge(store.runtimeBadges, { + sessionId: state.id, + workspaceId: snapshot.state.workspaceId, + status: snapshot.state.status, + updatedAt: new Date().toISOString(), + }), + })) + return state.id + } catch (error) { + set({ + loading: false, + connected: false, + error: getErrorMessage(error, 'Failed to create live session'), + }) + return null + } + }, + + async attachSession(sessionId) { + const target = sessionId.trim() + if (!target) return + + set({ loading: true, error: null }) + try { + const snapshot = await chatApi.attachLiveSession(target) + activeSessionId = target + startSessionStream(target, set) set((state) => ({ + loading: false, connected: true, liveSession: snapshot.state, turns: normalizeTurns(snapshot.turns), + systemMessages: [], currentContextTokens: normalizeNonNegativeNumber( snapshot.state.currentContextTokens, ), @@ -656,248 +918,163 @@ function ensureSubscriptions( snapshot.state.currentContextTokens ?? 0, snapshot.state.contextWindow ?? 0, ), - error: null, runtimeBadges: withRuntimeBadge(state.runtimeBadges, { - sessionId: currentSessionId, + sessionId: target, workspaceId: snapshot.state.workspaceId, status: snapshot.state.status, updatedAt: new Date().toISOString(), }), })) } catch (error) { - if (activeSessionId !== currentSessionId) return + if (isNotFoundError(error)) { + try { + const detail = await sessionsApi.getSessionDetail(target) + stopSessionStream() + activeSessionId = null + set({ + loading: false, + connected: false, + liveSession: null, + turns: toTurnsFromSessionDetail(detail), + systemMessages: [], + currentContextTokens: 0, + contextLimit: 0, + contextPercent: 0, + error: null, + }) + return + } catch (detailError) { + set({ + loading: false, + connected: false, + error: getErrorMessage(detailError, 'Failed to load session history'), + }) + return + } + } + set({ + loading: false, connected: false, - error: getErrorMessage(error, 'Failed to restore chat session after reconnect'), + error: getErrorMessage(error, 'Failed to attach session'), }) } - }) -} + }, -export const useChatStore = create((set, get) => { - ensureSubscriptions(set) + async sendInput(value) { + const sessionId = activeSessionId + if (!sessionId) return null - return { - liveSession: null, - turns: [], - systemMessages: [], - runtimeBadges: {}, - currentContextTokens: 0, - contextLimit: 0, - contextPercent: 0, - loading: false, - connected: false, - error: null, - - clearError() { - set({ error: null }) - }, - - setRuntimeBadges(items) { - const next: Record = {} - for (const item of items) { - next[item.sessionId] = item - } - set({ runtimeBadges: next }) - }, - - async createSession(workspaceId) { - const targetWorkspaceId = workspaceId.trim() - if (!targetWorkspaceId) { - set({ error: 'workspaceId is required' }) - return null - } + const input = value.trim() + if (!input) return null - set({ loading: true, error: null }) - try { - const state = await chatApi.createLiveSession({ - workspaceId: targetWorkspaceId, - }) - const snapshot = await chatApi.attachLiveSession(state.id) - activeSessionId = state.id - set((store) => ({ - liveSession: snapshot.state, - turns: normalizeTurns(snapshot.turns), - systemMessages: [], - currentContextTokens: normalizeNonNegativeNumber( - snapshot.state.currentContextTokens, - ), - contextLimit: normalizeNonNegativeNumber(snapshot.state.contextWindow), - contextPercent: calculateContextPercent( - snapshot.state.currentContextTokens ?? 0, - snapshot.state.contextWindow ?? 0, - ), - loading: false, - connected: true, - runtimeBadges: withRuntimeBadge(store.runtimeBadges, { - sessionId: state.id, - workspaceId: snapshot.state.workspaceId, - status: snapshot.state.status, - updatedAt: new Date().toISOString(), - }), - })) - return state.id - } catch (error) { - set({ - loading: false, - connected: false, - error: getErrorMessage(error, 'Failed to create live session'), - }) - return null - } - }, - - async attachSession(sessionId) { - const target = sessionId.trim() - if (!target) return - - set({ loading: true, error: null }) - try { - const snapshot = await chatApi.attachLiveSession(target) - activeSessionId = target - set((state) => ({ - loading: false, - connected: true, - liveSession: snapshot.state, - turns: normalizeTurns(snapshot.turns), - systemMessages: [], - currentContextTokens: normalizeNonNegativeNumber( - snapshot.state.currentContextTokens, - ), - contextLimit: normalizeNonNegativeNumber(snapshot.state.contextWindow), - contextPercent: calculateContextPercent( - snapshot.state.currentContextTokens ?? 0, - snapshot.state.contextWindow ?? 0, - ), - runtimeBadges: withRuntimeBadge(state.runtimeBadges, { - sessionId: target, - workspaceId: snapshot.state.workspaceId, - status: snapshot.state.status, - updatedAt: new Date().toISOString(), - }), - })) - } catch (error) { - set({ - loading: false, - connected: false, - error: getErrorMessage(error, 'Failed to attach session'), - }) - } - }, + set({ error: null }) + + try { + const result = await chatApi.submitSessionInput(sessionId, input) + return result + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to send chat input') }) + return null + } + }, - async sendInput(value) { - const sessionId = activeSessionId - if (!sessionId) return null + async removeQueuedInput(queueId) { + const sessionId = activeSessionId + if (!sessionId) return false + const targetQueueId = queueId.trim() + if (!targetQueueId) return false - const input = value.trim() - if (!input) return null + set({ error: null }) + try { + const result = await chatApi.removeQueuedInput(sessionId, targetQueueId) + return result.removed + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to remove queued message') }) + return false + } + }, - set({ error: null }) + async sendQueuedInputNow() { + const sessionId = activeSessionId + if (!sessionId) return false - try { - const result = await chatApi.submitSessionInput(sessionId, input) - if (!result.accepted && result.message) { - set({ error: result.message }) - } - return result - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to send chat input') }) - return null - } - }, - - async removeQueuedInput(queueId) { - const sessionId = activeSessionId - if (!sessionId) return false - const targetQueueId = queueId.trim() - if (!targetQueueId) return false - - set({ error: null }) - try { - const result = await chatApi.removeQueuedInput(sessionId, targetQueueId) - return result.removed - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to remove queued message') }) - return false - } - }, - - async sendQueuedInputNow() { - const sessionId = activeSessionId - if (!sessionId) return false - - set({ error: null }) - try { - const result = await chatApi.sendQueuedInputNow(sessionId) - return result.triggered - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to send queued message now') }) - return false - } - }, - - async cancelCurrentTurn() { - const sessionId = activeSessionId - if (!sessionId) return - try { - await chatApi.cancelSessionTurn(sessionId) - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to cancel current turn') }) - } - }, - - async approvePendingApproval(decision) { - const sessionId = activeSessionId - const pendingApproval = get().liveSession?.pendingApproval - if (!sessionId || !pendingApproval?.fingerprint) return false - - try { - await chatApi.approveSessionAction(sessionId, pendingApproval.fingerprint, decision) - return true - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to submit approval decision') }) - return false - } - }, - - async compactCurrentSession() { - const sessionId = activeSessionId - if (!sessionId) return - try { - await chatApi.compactSession(sessionId) - } catch (error) { - set({ error: getErrorMessage(error, 'Failed to compact session') }) - } - }, + set({ error: null }) + try { + const result = await chatApi.sendQueuedInputNow(sessionId) + return result.triggered + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to send queued message now') }) + return false + } + }, - connectStream(sessionId) { - void get().attachSession(sessionId) - }, + async cancelCurrentTurn() { + const sessionId = activeSessionId + if (!sessionId) return + try { + await chatApi.cancelSessionTurn(sessionId) + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to cancel current turn') }) + } + }, - disconnectStream() { - activeSessionId = null - set({ - connected: false, - currentContextTokens: 0, - contextLimit: 0, - contextPercent: 0, - }) - }, + async approvePendingApproval(decision) { + const sessionId = activeSessionId + const pendingApproval = get().liveSession?.pendingApproval + if (!sessionId || !pendingApproval?.fingerprint) return false - reset() { - activeSessionId = null - set({ - liveSession: null, - turns: [], - systemMessages: [], - loading: false, - connected: false, - runtimeBadges: {}, - currentContextTokens: 0, - contextLimit: 0, - contextPercent: 0, - error: null, - }) - }, - } -}) + try { + await chatApi.approveSessionAction(sessionId, pendingApproval.fingerprint, decision) + return true + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to submit approval decision') }) + return false + } + }, + + async compactCurrentSession() { + const sessionId = activeSessionId + if (!sessionId) return + try { + await chatApi.compactSession(sessionId) + } catch (error) { + set({ error: getErrorMessage(error, 'Failed to compact session') }) + } + }, + + connectStream(sessionId) { + const target = sessionId.trim() + if (!target) return + activeSessionId = target + startSessionStream(target, set) + }, + + disconnectStream() { + stopSessionStream() + activeSessionId = null + set({ + connected: false, + currentContextTokens: 0, + contextLimit: 0, + contextPercent: 0, + }) + }, + + reset() { + stopSessionStream() + activeSessionId = null + set({ + liveSession: null, + turns: [], + systemMessages: [], + loading: false, + connected: false, + runtimeBadges: {}, + currentContextTokens: 0, + contextLimit: 0, + contextPercent: 0, + error: null, + }) + }, +})) diff --git a/packages/web-ui/src/stores/skills-store.ts b/packages/web-ui/src/stores/skills-store.ts index d759474..a12e135 100644 --- a/packages/web-ui/src/stores/skills-store.ts +++ b/packages/web-ui/src/stores/skills-store.ts @@ -11,11 +11,11 @@ type SkillsStore = { load: (query?: { scope?: 'project' | 'global' q?: string - workspaceId?: string + workspaceCwd?: string }) => Promise create: (payload: { scope: 'project' | 'global' - workspaceId?: string + workspaceCwd?: string name: string description?: string content?: string @@ -34,9 +34,12 @@ export const useSkillsStore = create((set, get) => ({ set({ loading: true, error: null }) try { const selectedWorkspaceId = useWorkspaceStore.getState().selectedWorkspaceId + const selectedWorkspaceCwd = + useWorkspaceStore.getState().items.find((item) => item.id === selectedWorkspaceId) + ?.cwd ?? undefined const response = await skillsApi.getSkills({ ...query, - workspaceId: query?.workspaceId ?? selectedWorkspaceId ?? undefined, + workspaceCwd: query?.workspaceCwd ?? selectedWorkspaceCwd, }) set({ items: response.items, loading: false }) } catch (error) { @@ -55,9 +58,13 @@ export const useSkillsStore = create((set, get) => ({ try { await skillsApi.createSkill({ ...payload, - workspaceId: - payload.workspaceId ?? - useWorkspaceStore.getState().selectedWorkspaceId ?? + workspaceCwd: + payload.workspaceCwd ?? + useWorkspaceStore + .getState() + .items.find( + (item) => item.id === useWorkspaceStore.getState().selectedWorkspaceId, + )?.cwd ?? undefined, name, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af34a5c..dd23644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ importers: .: dependencies: - '@dqbd/tiktoken': - specifier: ^1.0.22 - version: 1.0.22 + '@ai-sdk/openai': + specifier: ^3.0.37 + version: 3.0.37(zod@4.3.6) '@inkjs/ui': specifier: ^2.0.0 version: 2.0.0(ink@6.7.0(@types/react@19.2.14)(react@19.2.4)) @@ -20,18 +20,9 @@ importers: '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 - '@nestjs/common': - specifier: ^11.0.1 - version: 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^11.0.1 - version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': - specifier: ^11.0.1 - version: 11.0.2(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/platform-express': - specifier: ^11.0.1 - version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + ai: + specifier: ^6.0.105 + version: 6.0.105(zod@4.3.6) fast-glob: specifier: ^3.3.3 version: 3.3.3 @@ -56,15 +47,9 @@ importers: react-reconciler: specifier: ^0.33.0 version: 0.33.0(react@19.2.4) - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 robots-parser: specifier: ^3.0.1 version: 3.0.1 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 string-width: specifier: ^7.2.0 version: 7.2.0 @@ -77,9 +62,6 @@ importers: undici: specifier: ^6.23.0 version: 6.23.0 - ws: - specifier: ^8.18.3 - version: 8.19.0 yaml: specifier: ^2.8.1 version: 2.8.2 @@ -116,7 +98,7 @@ importers: version: 5.9.3 vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.7)(typescript@5.9.3))(terser@5.46.0) @@ -190,112 +172,6 @@ importers: specifier: ^19.2.14 version: 19.2.14 - packages/web-server: - dependencies: - '@memo-code/core': - specifier: workspace:* - version: link:../core - '@nestjs/common': - specifier: ^11.0.1 - version: 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^11.0.1 - version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': - specifier: ^11.0.1 - version: 11.0.2(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/platform-express': - specifier: ^11.0.1 - version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) - openai: - specifier: ^6.10.0 - version: 6.17.0(ws@8.19.0)(zod@4.3.6) - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - toml: - specifier: ^3.0.0 - version: 3.0.0 - ws: - specifier: ^8.18.3 - version: 8.19.0 - yaml: - specifier: ^2.8.1 - version: 2.8.2 - devDependencies: - '@eslint/eslintrc': - specifier: ^3.2.0 - version: 3.3.3 - '@eslint/js': - specifier: ^9.18.0 - version: 9.39.2 - '@nestjs/cli': - specifier: ^11.0.0 - version: 11.0.16(@types/node@22.19.7)(esbuild@0.27.2) - '@nestjs/schematics': - specifier: ^11.0.0 - version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) - '@nestjs/testing': - specifier: ^11.0.1 - version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-express@11.1.13) - '@types/express': - specifier: ^5.0.0 - version: 5.0.6 - '@types/jest': - specifier: ^30.0.0 - version: 30.0.0 - '@types/node': - specifier: ^22.10.7 - version: 22.19.7 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - '@types/ws': - specifier: ^8.5.17 - version: 8.18.1 - eslint: - specifier: ^9.18.0 - version: 9.39.2(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.0.1 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.2.2 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) - globals: - specifier: ^16.0.0 - version: 16.5.0 - prettier: - specifier: ^3.4.2 - version: 3.8.1 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - supertest: - specifier: ^7.0.0 - version: 7.2.2 - ts-jest: - specifier: ^29.2.5 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)))(typescript@5.9.3) - ts-loader: - specifier: ^9.5.2 - version: 9.5.4(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.2)) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.7)(typescript@5.9.3) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - typescript-eslint: - specifier: ^8.20.0 - version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - packages/web-ui: dependencies: '@memo-code/core': @@ -310,9 +186,6 @@ importers: '@streamdown/math': specifier: ^1.0.2 version: 1.0.2(react@19.2.4) - '@streamdown/mermaid': - specifier: ^1.0.2 - version: 1.0.2(react@19.2.4) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -407,47 +280,35 @@ packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} - '@alcalzone/ansi-tokenize@0.2.5': - resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + '@ai-sdk/gateway@3.0.59': + resolution: {integrity: sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==} engines: {node: '>=18'} - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@angular-devkit/core@19.2.17': - resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: - chokidar: ^4.0.0 - peerDependenciesMeta: - chokidar: - optional: true + zod: ^3.25.76 || ^4.1.8 - '@angular-devkit/core@19.2.19': - resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@ai-sdk/openai@3.0.37': + resolution: {integrity: sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow==} + engines: {node: '>=18'} peerDependencies: - chokidar: ^4.0.0 - peerDependenciesMeta: - chokidar: - optional: true + zod: ^3.25.76 || ^4.1.8 - '@angular-devkit/schematics-cli@19.2.19': - resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - hasBin: true + '@ai-sdk/provider-utils@4.0.16': + resolution: {integrity: sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 - '@angular-devkit/schematics@19.2.17': - resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} - '@angular-devkit/schematics@19.2.19': - resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} '@antfu/ni@25.0.0': resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} @@ -550,91 +411,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} @@ -686,39 +468,10 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@borewit/text-codec@0.2.1': - resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} - - '@braintree/sanitize-url@7.1.2': - resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} - '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@chevrotain/cst-dts-gen@11.0.3': - resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} - - '@chevrotain/gast@11.0.3': - resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} - - '@chevrotain/regexp-to-ast@11.0.3': - resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} - - '@chevrotain/types@11.0.3': - resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} - - '@chevrotain/utils@11.0.3': - resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -754,24 +507,12 @@ packages: resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true - '@dqbd/tiktoken@1.0.22': - resolution: {integrity: sha512-RYhO8xeHkMNX5Ixqf4M1Ve3siCYJY/dI0yLnlX4M4oIEDOvjMIQ+E+3OUpAaZcWTaMtQJzGcDAghYfllpx3i/w==} - '@ecies/ciphers@0.2.5': resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1150,12 +891,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - - '@iconify/utils@3.1.0': - resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} - '@inkjs/ui@2.0.0': resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} engines: {node: '>=18'} @@ -1166,15 +901,6 @@ packages: resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} - '@inquirer/checkbox@4.3.2': - resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/confirm@5.1.21': resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} @@ -1193,109 +919,10 @@ packages: '@types/node': optional: true - '@inquirer/editor@4.2.23': - resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.23': - resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@1.0.3': - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/figures@1.0.15': resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} - '@inquirer/input@4.3.1': - resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.23': - resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.23': - resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.10.1': - resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.3.2': - resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.1.11': - resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.2.2': - resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@4.4.2': - resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/type@3.0.10': resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} @@ -1313,96 +940,10 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/console@30.2.0': - resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/core@30.2.0': - resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/diff-sequences@30.0.1': - resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/environment@30.2.0': - resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect-utils@30.2.0': - resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect@30.2.0': - resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/fake-timers@30.2.0': - resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/get-type@30.1.0': - resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/globals@30.2.0': - resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/pattern@30.0.1': - resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/reporters@30.2.0': - resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@30.0.5': - resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/snapshot-utils@30.2.0': - resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/source-map@30.0.1': - resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/test-result@30.2.0': - resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/test-sequencer@30.2.0': - resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/transform@30.2.0': - resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/types@30.2.0': - resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1422,16 +963,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@lukeed/csprng@1.1.0': - resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} - engines: {node: '>=8'} - - '@mermaid-js/parser@0.6.3': - resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} - '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -1463,93 +994,17 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} - '@nestjs/cli@11.0.16': - resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} - engines: {node: '>= 20.11'} - hasBin: true - peerDependencies: - '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 - '@swc/core': ^1.3.62 - peerDependenciesMeta: - '@swc/cli': - optional: true - '@swc/core': - optional: true + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} - '@nestjs/common@11.1.13': - resolution: {integrity: sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==} - peerDependencies: - class-transformer: '>=0.4.1' - class-validator: '>=0.13.2' - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - - '@nestjs/core@11.1.13': - resolution: {integrity: sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==} - engines: {node: '>= 20'} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 - '@nestjs/websockets': ^11.0.0 - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - '@nestjs/websockets': - optional: true - - '@nestjs/jwt@11.0.2': - resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} - peerDependencies: - '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - - '@nestjs/platform-express@11.1.13': - resolution: {integrity: sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - - '@nestjs/schematics@11.0.9': - resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} - peerDependencies: - typescript: '>=4.8.2' - - '@nestjs/testing@11.1.13': - resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - - '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1563,11 +1018,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nuxt/opencollective@0.4.1': - resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} - engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} - hasBin: true - '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -1577,17 +1027,14 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2430,18 +1877,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sinclair/typebox@0.34.48': - resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} - '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@13.0.5': - resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@streamdown/cjk@1.0.2': resolution: {integrity: sha512-5OOuZjj2Lnae92Zmg2gA5hloSbcKj25gv+QY4iKbYI+iRsiGWbgmYxmgxNUSO9SR6BKOCy783UHN1HM/QEUpdw==} @@ -2458,11 +1899,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - '@streamdown/mermaid@1.0.2': - resolution: {integrity: sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -2553,31 +1989,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tokenizer/inflate@0.4.1': - resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} - engines: {node: '>=18'} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2590,165 +2004,27 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-axis@3.0.6': - resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} - - '@types/d3-brush@3.0.6': - resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} - - '@types/d3-chord@3.0.6': - resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-contour@3.0.6': - resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} - - '@types/d3-delaunay@6.0.4': - resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - - '@types/d3-dispatch@3.0.7': - resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} - - '@types/d3-drag@3.0.7': - resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - - '@types/d3-dsv@3.0.7': - resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-fetch@3.0.7': - resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} - - '@types/d3-force@3.0.10': - resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} - - '@types/d3-format@3.0.4': - resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - - '@types/d3-geo@3.1.0': - resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} - - '@types/d3-hierarchy@3.1.7': - resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-polygon@3.0.2': - resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} - - '@types/d3-quadtree@3.0.6': - resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} - - '@types/d3-scale-chromatic@3.1.0': - resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} - - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} - - '@types/d3-time-format@4.0.3': - resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/d3-transition@3.0.9': - resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} - - '@types/d3@7.4.3': - resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@30.0.0': - resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2758,12 +2034,6 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2772,27 +2042,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2802,15 +2054,6 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2873,100 +2116,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} @@ -3012,76 +2164,15 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -3091,13 +2182,11 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + ai@6.0.105: + resolution: {integrity: sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==} + engines: {node: '>=18'} peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true + zod: ^3.25.76 || ^4.1.8 ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} @@ -3107,30 +2196,12 @@ packages: ajv: optional: true - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} - peerDependencies: - ajv: ^8.8.2 - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -3147,10 +2218,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3162,19 +2229,6 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3182,12 +2236,6 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - array-timsort@1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3206,31 +2254,6 @@ packages: axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - babel-jest@30.2.0: - resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-0 - - babel-plugin-istanbul@7.0.1: - resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} - engines: {node: '>=12'} - - babel-plugin-jest-hoist@30.2.0: - resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@30.2.0: - resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-beta.1 - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3241,9 +2264,6 @@ packages: resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} engines: {node: 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -3251,9 +2271,6 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3277,22 +2294,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3303,10 +2307,6 @@ packages: peerDependencies: esbuild: '>=0.18' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3327,14 +2327,6 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} @@ -3353,10 +2345,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3369,36 +2357,14 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - chevrotain-allstar@0.3.1: - resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} - peerDependencies: - chevrotain: ^11.0.0 - - chevrotain@11.0.3: - resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - - cjs-module-lexer@2.2.0: - resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3406,10 +2372,6 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - cli-cursor@4.0.0: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3426,10 +2388,6 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - cli-truncate@5.1.1: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} @@ -3442,18 +2400,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -3461,9 +2411,6 @@ packages: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3493,28 +2440,13 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - comment-json@4.4.1: - resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} - engines: {node: '>= 6'} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -3549,24 +2481,12 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - cose-base@1.0.3: - resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} - - cose-base@2.2.0: - resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - - cosmiconfig@8.3.6: - resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -3574,18 +2494,6 @@ packages: typescript: optional: true - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3606,162 +2514,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cytoscape-cose-bilkent@4.1.0: - resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} - peerDependencies: - cytoscape: ^3.2.0 - - cytoscape-fcose@2.2.0: - resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} - peerDependencies: - cytoscape: ^3.2.0 - - cytoscape@3.33.1: - resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} - engines: {node: '>=0.10'} - - d3-array@2.12.1: - resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-axis@3.0.0: - resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} - engines: {node: '>=12'} - - d3-brush@3.0.0: - resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} - engines: {node: '>=12'} - - d3-chord@3.0.1: - resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-contour@4.0.2: - resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} - engines: {node: '>=12'} - - d3-delaunay@6.0.4: - resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} - engines: {node: '>=12'} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-dsv@3.0.1: - resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} - engines: {node: '>=12'} - hasBin: true - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-fetch@3.0.1: - resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-geo@3.1.1: - resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} - engines: {node: '>=12'} - - d3-hierarchy@3.1.2: - resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@1.0.9: - resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-polygon@3.0.1: - resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-random@3.0.1: - resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} - engines: {node: '>=12'} - - d3-sankey@0.12.3: - resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} - - d3-scale-chromatic@3.1.0: - resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-shape@1.3.7: - resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: 2 - 3 - - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} - - d3@7.9.0: - resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} - engines: {node: '>=12'} - - dagre-d3-es@7.0.13: - resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -3770,9 +2522,6 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3815,16 +2564,10 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - delaunator@5.0.1: - resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3841,30 +2584,16 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} - dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -3876,9 +2605,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - eciesjs@0.4.17: resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -3889,10 +2615,6 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3936,9 +2658,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3979,26 +2698,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - eslint-plugin-react-hooks@7.0.1: resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} @@ -4010,10 +2709,6 @@ packages: peerDependencies: eslint: '>=8.40' - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4053,10 +2748,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -4075,10 +2766,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -4095,18 +2782,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - exit-x@0.2.2: - resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} - engines: {node: '>= 0.8.0'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - expect@30.2.0: - resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -4129,9 +2808,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4142,18 +2818,12 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4175,10 +2845,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} - engines: {node: '>=20'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -4187,10 +2853,6 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4218,13 +2880,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - fork-ts-checker-webpack-plugin@9.1.0: - resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} - engines: {node: '>=14.21.3'} - peerDependencies: - typescript: '>3.6.0' - webpack: ^5.11.0 - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -4233,10 +2888,6 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} - formidable@3.5.4: - resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} - engines: {node: '>=14.0.0'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4245,20 +2896,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} - fs-monkey@1.1.0: - resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4297,10 +2938,6 @@ packages: resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} engines: {node: '>=14.16'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -4324,22 +2961,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.0: - resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} - engines: {node: 20 || >=22} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -4362,14 +2988,6 @@ packages: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - hachure-fill@0.5.2: - resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4474,17 +3092,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4497,11 +3108,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4510,10 +3116,6 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4533,13 +3135,6 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - internmap@1.0.1: - resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -4581,10 +3176,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -4606,10 +3197,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -4647,10 +3234,6 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -4674,10 +3257,6 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} @@ -4690,10 +3269,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - iterare@1.2.1: - resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} - engines: {node: '>=6'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4701,138 +3276,6 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} - jest-changed-files@30.2.0: - resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-circus@30.2.0: - resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-cli@30.2.0: - resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@30.2.0: - resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@types/node': '*' - esbuild-register: '>=3.4.0' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - esbuild-register: - optional: true - ts-node: - optional: true - - jest-diff@30.2.0: - resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-docblock@30.2.0: - resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-each@30.2.0: - resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-environment-node@30.2.0: - resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-haste-map@30.2.0: - resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-leak-detector@30.2.0: - resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-matcher-utils@30.2.0: - resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-message-util@30.2.0: - resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-mock@30.2.0: - resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@30.0.1: - resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-resolve-dependencies@30.2.0: - resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-resolve@30.2.0: - resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-runner@30.2.0: - resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-runtime@30.2.0: - resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-snapshot@30.2.0: - resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-util@30.2.0: - resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-validate@30.2.0: - resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-watcher@30.2.0: - resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - - jest-worker@30.2.0: - resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest@30.2.0: - resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4847,10 +3290,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -4884,6 +3323,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4892,22 +3334,9 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - katex@0.16.28: resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} hasBin: true @@ -4915,9 +3344,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - khroma@2.1.0: - resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -4926,20 +3352,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - langium@3.3.1: - resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} - engines: {node: '>=16.0.0'} - - layout-base@1.0.2: - resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} - - layout-base@2.0.1: - resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -5021,66 +3433,17 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - load-esm@1.0.3: - resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} - engines: {node: '>=13.2.0'} - load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} - engines: {node: '>=6.11.5'} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - - lodash-es@4.17.23: - resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -5106,9 +3469,6 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5119,20 +3479,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@16.4.2: - resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} - engines: {node: '>= 20'} - hasBin: true - marked@17.0.1: resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} engines: {node: '>= 20'} @@ -5193,18 +3542,10 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memfs@3.5.3: - resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} - engines: {node: '>= 4.0.0'} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -5216,13 +3557,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.12.2: - resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5359,11 +3693,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5390,10 +3719,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -5410,10 +3735,6 @@ packages: typescript: optional: true - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} - mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5426,11 +3747,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5438,40 +3754,24 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead - node-emoji@1.11.0: - resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} - node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5533,10 +3833,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -5544,26 +3840,14 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5602,17 +3886,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-data-parser@0.1.0: - resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5625,20 +3902,12 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -5656,10 +3925,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -5672,23 +3937,9 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - - points-on-curve@0.2.0: - resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} - - points-on-path@0.2.1: - resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -5723,19 +3974,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} - prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true - pretty-format@30.2.0: - resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5758,9 +4001,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@7.0.1: - resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -5781,9 +4021,6 @@ packages: '@types/react-dom': optional: true - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5797,9 +4034,6 @@ packages: peerDependencies: react: ^19.2.4 - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -5861,10 +4095,6 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5873,9 +4103,6 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -5943,10 +4170,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5958,10 +4181,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5981,17 +4200,11 @@ packages: resolution: {integrity: sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==} engines: {node: '>=10.0.0'} - robust-predicates@3.0.2: - resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - roughjs@4.6.6: - resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -6003,18 +4216,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6025,14 +4226,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} - - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6046,9 +4239,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -6103,10 +4293,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -6121,9 +4307,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -6131,10 +4314,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -6142,9 +4321,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -6168,17 +4344,9 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6195,9 +4363,6 @@ packages: resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} engines: {node: '>=20'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6217,10 +4382,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -6233,51 +4394,24 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} - engines: {node: '>=18'} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true - superagent@10.3.0: - resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} - engines: {node: '>=14.18.0'} - - supertest@7.2.2: - resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} - engines: {node: '>=14.18.0'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6296,31 +4430,11 @@ packages: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} - terser-webpack-plugin@5.3.16: - resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - terser@5.46.0: resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} engines: {node: '>=10'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - test-exclude@7.0.1: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} @@ -6368,9 +4482,6 @@ packages: resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} hasBin: true - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6379,10 +4490,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - token-types@6.1.2: - resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} - engines: {node: '>=14.16'} - toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} @@ -6410,64 +4517,12 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-dedent@2.2.0: - resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} - engines: {node: '>=6.10'} - ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - - ts-loader@9.5.4: - resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==} - engines: {node: '>=12.0.0'} - peerDependencies: - typescript: '*' - webpack: ^5.0.0 - ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -6478,10 +4533,6 @@ packages: typescript: optional: true - tsconfig-paths-webpack-plugin@4.2.0: - resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} - engines: {node: '>=10.13.0'} - tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -6523,33 +4574,14 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-fest@5.4.4: resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.55.0: resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6565,19 +4597,6 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - uid@2.0.2: - resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} - engines: {node: '>=8'} - - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6628,9 +4647,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -6671,17 +4687,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6805,40 +4810,10 @@ packages: jsdom: optional: true - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} - engines: {node: '>=10.13.0'} - - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -6850,24 +4825,6 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} - webpack-node-externals@3.0.0: - resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} - engines: {node: '>=6'} - - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} - engines: {node: '>=10.13.0'} - - webpack@5.104.1: - resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -6899,9 +4856,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -6921,10 +4875,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -6948,10 +4898,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6972,10 +4918,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -7033,74 +4975,39 @@ snapshots: '@acemir/cssom@0.9.31': {} - '@alcalzone/ansi-tokenize@0.2.5': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + '@ai-sdk/gateway@3.0.59(zod@4.3.6)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - jsonc-parser: 3.3.1 - picomatch: 4.0.2 - rxjs: 7.8.1 - source-map: 0.7.4 - optionalDependencies: - chokidar: 4.0.3 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.16(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 - '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + '@ai-sdk/openai@3.0.37(zod@4.3.6)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - jsonc-parser: 3.3.1 - picomatch: 4.0.2 - rxjs: 7.8.1 - source-map: 0.7.4 - optionalDependencies: - chokidar: 4.0.3 + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.16(zod@4.3.6) + zod: 4.3.6 - '@angular-devkit/schematics-cli@19.2.19(@types/node@22.19.7)(chokidar@4.0.3)': + '@ai-sdk/provider-utils@4.0.16(zod@4.3.6)': dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@22.19.7) - ansi-colors: 4.1.3 - symbol-observable: 4.0.0 - yargs-parser: 21.1.1 - transitivePeerDependencies: - - '@types/node' - - chokidar + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 - '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + '@ai-sdk/provider@3.0.8': dependencies: - '@angular-devkit/core': 19.2.17(chokidar@4.0.3) - jsonc-parser: 3.3.1 - magic-string: 0.30.17 - ora: 5.4.1 - rxjs: 7.8.1 - transitivePeerDependencies: - - chokidar + json-schema: 0.4.0 - '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + '@alcalzone/ansi-tokenize@0.2.5': dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - jsonc-parser: 3.3.1 - magic-string: 0.30.17 - ora: 5.4.1 - rxjs: 7.8.1 - transitivePeerDependencies: - - chokidar + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 - '@antfu/install-pkg@1.1.0': + '@ampproject/remapping@2.3.0': dependencies: - package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@antfu/ni@25.0.0': dependencies: @@ -7250,86 +5157,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -7400,41 +5232,13 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@borewit/text-codec@0.2.1': {} - - '@braintree/sanitize-url@7.1.2': {} - '@bramus/specificity@2.4.2': dependencies: css-tree: 3.1.0 - '@chevrotain/cst-dts-gen@11.0.3': - dependencies: - '@chevrotain/gast': 11.0.3 - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 + '@csstools/color-helpers@6.0.2': {} - '@chevrotain/gast@11.0.3': - dependencies: - '@chevrotain/types': 11.0.3 - lodash-es: 4.17.21 - - '@chevrotain/regexp-to-ast@11.0.3': {} - - '@chevrotain/types@11.0.3': {} - - '@chevrotain/utils@11.0.3': {} - - '@colors/colors@1.5.0': - optional: true - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@csstools/color-helpers@6.0.2': {} - - '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -7466,28 +5270,10 @@ snapshots: picomatch: 4.0.3 which: 4.0.0 - '@dqbd/tiktoken@1.0.22': {} - '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.8.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -7717,14 +5503,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iconify/types@2.0.0': {} - - '@iconify/utils@3.1.0': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@iconify/types': 2.0.0 - mlly: 1.8.0 - '@inkjs/ui@2.0.0(ink@6.7.0(@types/react@19.2.14)(react@19.2.4))': dependencies: chalk: 5.6.2 @@ -7735,22 +5513,13 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@22.19.7)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.7) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.7 - '@inquirer/confirm@5.1.21(@types/node@22.19.7)': dependencies: '@inquirer/core': 10.3.2(@types/node@22.19.7) '@inquirer/type': 3.0.10(@types/node@22.19.7) optionalDependencies: '@types/node': 22.19.7 + optional: true '@inquirer/confirm@5.1.21(@types/node@24.10.13)': dependencies: @@ -7771,6 +5540,7 @@ snapshots: yoctocolors-cjs: 2.1.3 optionalDependencies: '@types/node': 22.19.7 + optional: true '@inquirer/core@10.3.2(@types/node@24.10.13)': dependencies: @@ -7785,113 +5555,12 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 - '@inquirer/editor@4.2.23(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/expand@4.0.23(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/external-editor@1.0.3(@types/node@22.19.7)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 22.19.7 - '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/number@3.0.23(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/password@4.0.23(@types/node@22.19.7)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/prompts@7.10.1(@types/node@22.19.7)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.7) - '@inquirer/confirm': 5.1.21(@types/node@22.19.7) - '@inquirer/editor': 4.2.23(@types/node@22.19.7) - '@inquirer/expand': 4.0.23(@types/node@22.19.7) - '@inquirer/input': 4.3.1(@types/node@22.19.7) - '@inquirer/number': 3.0.23(@types/node@22.19.7) - '@inquirer/password': 4.0.23(@types/node@22.19.7) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.7) - '@inquirer/search': 3.2.2(@types/node@22.19.7) - '@inquirer/select': 4.4.2(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/prompts@7.3.2(@types/node@22.19.7)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.7) - '@inquirer/confirm': 5.1.21(@types/node@22.19.7) - '@inquirer/editor': 4.2.23(@types/node@22.19.7) - '@inquirer/expand': 4.0.23(@types/node@22.19.7) - '@inquirer/input': 4.3.1(@types/node@22.19.7) - '@inquirer/number': 3.0.23(@types/node@22.19.7) - '@inquirer/password': 4.0.23(@types/node@22.19.7) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.7) - '@inquirer/search': 3.2.2(@types/node@22.19.7) - '@inquirer/select': 4.4.2(@types/node@22.19.7) - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/rawlist@4.1.11(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/type': 3.0.10(@types/node@22.19.7) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/search@3.2.2(@types/node@22.19.7)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.7) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.7 - - '@inquirer/select@4.4.2(@types/node@22.19.7)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.7) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.7) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.7 - '@inquirer/type@3.0.10(@types/node@22.19.7)': optionalDependencies: '@types/node': 22.19.7 + optional: true '@inquirer/type@3.0.10(@types/node@24.10.13)': optionalDependencies: @@ -7908,195 +5577,8 @@ snapshots: '@isaacs/cliui@9.0.0': {} - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - '@istanbuljs/schema@0.1.3': {} - '@jest/console@30.2.0': - dependencies: - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - chalk: 4.1.2 - jest-message-util: 30.2.0 - jest-util: 30.2.0 - slash: 3.0.0 - - '@jest/core@30.2.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.2.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.4.0 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-resolve-dependencies: 30.2.0 - jest-runner: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - jest-watcher: 30.2.0 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - - '@jest/diff-sequences@30.0.1': {} - - '@jest/environment@30.2.0': - dependencies: - '@jest/fake-timers': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - jest-mock: 30.2.0 - - '@jest/expect-utils@30.2.0': - dependencies: - '@jest/get-type': 30.1.0 - - '@jest/expect@30.2.0': - dependencies: - expect: 30.2.0 - jest-snapshot: 30.2.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@30.2.0': - dependencies: - '@jest/types': 30.2.0 - '@sinonjs/fake-timers': 13.0.5 - '@types/node': 22.19.7 - jest-message-util: 30.2.0 - jest-mock: 30.2.0 - jest-util: 30.2.0 - - '@jest/get-type@30.1.0': {} - - '@jest/globals@30.2.0': - dependencies: - '@jest/environment': 30.2.0 - '@jest/expect': 30.2.0 - '@jest/types': 30.2.0 - jest-mock: 30.2.0 - transitivePeerDependencies: - - supports-color - - '@jest/pattern@30.0.1': - dependencies: - '@types/node': 22.19.7 - jest-regex-util: 30.0.1 - - '@jest/reporters@30.2.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.19.7 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit-x: 0.2.2 - glob: 10.5.0 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - jest-message-util: 30.2.0 - jest-util: 30.2.0 - jest-worker: 30.2.0 - slash: 3.0.0 - string-length: 4.0.2 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@30.0.5': - dependencies: - '@sinclair/typebox': 0.34.48 - - '@jest/snapshot-utils@30.2.0': - dependencies: - '@jest/types': 30.2.0 - chalk: 4.1.2 - graceful-fs: 4.2.11 - natural-compare: 1.4.0 - - '@jest/source-map@30.0.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@30.2.0': - dependencies: - '@jest/console': 30.2.0 - '@jest/types': 30.2.0 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@30.2.0': - dependencies: - '@jest/test-result': 30.2.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.2.0 - slash: 3.0.0 - - '@jest/transform@30.2.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 30.2.0 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 7.0.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.2.0 - jest-regex-util: 30.0.1 - jest-util: 30.2.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 5.0.1 - transitivePeerDependencies: - - supports-color - - '@jest/types@30.2.0': - dependencies: - '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.5 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.7 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8113,6 +5595,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + optional: true '@jridgewell/sourcemap-codec@1.5.5': {} @@ -8121,17 +5604,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@lukeed/csprng@1.1.0': {} - - '@mermaid-js/parser@0.6.3': - dependencies: - langium: 3.3.1 - '@mixmark-io/domino@2.2.0': {} '@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)': @@ -8189,102 +5661,6 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@nestjs/cli@11.0.16(@types/node@22.19.7)(esbuild@0.27.2)': - dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@22.19.7)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@22.19.7) - '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.2)) - glob: 13.0.0 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.104.1(esbuild@0.27.2) - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - '@types/node' - - esbuild - - uglify-js - - webpack-cli - - '@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - file-type: 21.3.0 - iterare: 1.2.1 - load-esm: 1.0.3 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - transitivePeerDependencies: - - supports-color - - '@nestjs/core@11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nuxt/opencollective': 0.4.1 - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 8.3.0 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) - - '@nestjs/jwt@11.0.2(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))': - dependencies: - '@nestjs/common': 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@types/jsonwebtoken': 9.0.10 - jsonwebtoken: 9.0.3 - - '@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': - dependencies: - '@nestjs/common': 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - cors: 2.8.6 - express: 5.2.1 - multer: 2.0.2 - path-to-regexp: 8.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - - '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': - dependencies: - '@angular-devkit/core': 19.2.17(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) - comment-json: 4.4.1 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar - - '@nestjs/testing@11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(@nestjs/platform-express@11.1.13)': - dependencies: - '@nestjs/common': 11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) - '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -8305,10 +5681,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nuxt/opencollective@0.4.1': - dependencies: - consola: 3.4.2 - '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -8318,15 +5690,11 @@ snapshots: '@open-draft/until@2.1.0': {} - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 + '@opentelemetry/api@1.9.0': {} '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/core@0.2.9': {} - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -9186,17 +6554,9 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@sinclair/typebox@0.34.48': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@13.0.5': - dependencies: - '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} '@streamdown/cjk@1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)': dependencies: @@ -9224,11 +6584,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@streamdown/mermaid@1.0.2(react@19.2.4)': - dependencies: - mermaid: 11.12.2 - react: 19.2.4 - '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9297,34 +6652,12 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@tokenizer/inflate@0.4.1': - dependencies: - debug: 4.4.3 - token-types: 6.1.2 - transitivePeerDependencies: - - supports-color - - '@tokenizer/token@0.3.0': {} - - '@ts-morph/common@0.27.0': + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 minimatch: 10.2.0 path-browserify: 1.0.1 - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -9346,205 +6679,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 22.19.7 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 22.19.7 - - '@types/cookiejar@2.1.5': {} - - '@types/d3-array@3.2.2': {} - - '@types/d3-axis@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-brush@3.0.6': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-chord@3.0.6': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-contour@3.0.6': - dependencies: - '@types/d3-array': 3.2.2 - '@types/geojson': 7946.0.16 - - '@types/d3-delaunay@6.0.4': {} - - '@types/d3-dispatch@3.0.7': {} - - '@types/d3-drag@3.0.7': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-dsv@3.0.7': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-fetch@3.0.7': - dependencies: - '@types/d3-dsv': 3.0.7 - - '@types/d3-force@3.0.10': {} - - '@types/d3-format@3.0.4': {} - - '@types/d3-geo@3.1.0': - dependencies: - '@types/geojson': 7946.0.16 - - '@types/d3-hierarchy@3.1.7': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-polygon@3.0.2': {} - - '@types/d3-quadtree@3.0.6': {} - - '@types/d3-random@3.0.3': {} - - '@types/d3-scale-chromatic@3.1.0': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-selection@3.0.11': {} - - '@types/d3-shape@3.1.8': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time-format@4.0.3': {} - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/d3-transition@3.0.9': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-zoom@3.0.8': - dependencies: - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - - '@types/d3@7.4.3': - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-axis': 3.0.6 - '@types/d3-brush': 3.0.6 - '@types/d3-chord': 3.0.6 - '@types/d3-color': 3.1.3 - '@types/d3-contour': 3.0.6 - '@types/d3-delaunay': 6.0.4 - '@types/d3-dispatch': 3.0.7 - '@types/d3-drag': 3.0.7 - '@types/d3-dsv': 3.0.7 - '@types/d3-ease': 3.0.2 - '@types/d3-fetch': 3.0.7 - '@types/d3-force': 3.0.10 - '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.1.0 - '@types/d3-hierarchy': 3.1.7 - '@types/d3-interpolate': 3.0.4 - '@types/d3-path': 3.1.1 - '@types/d3-polygon': 3.0.2 - '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 - '@types/d3-scale': 4.0.9 - '@types/d3-scale-chromatic': 3.1.0 - '@types/d3-selection': 3.0.11 - '@types/d3-shape': 3.1.8 - '@types/d3-time': 3.0.4 - '@types/d3-time-format': 4.0.3 - '@types/d3-timer': 3.0.2 - '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 - - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 '@types/estree@1.0.8': {} - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 22.19.7 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - - '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 - '@types/http-errors@2.0.5': {} - - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@30.0.0': - dependencies: - expect: 30.2.0 - pretty-format: 30.2.0 - '@types/json-schema@7.0.15': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.19.7 - '@types/katex@0.16.8': {} '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 - '@types/methods@1.1.4': {} - '@types/ms@2.1.0': {} '@types/node@22.19.7': @@ -9555,10 +6711,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/qs@6.14.0': {} - - '@types/range-parser@1.2.7': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -9567,50 +6719,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/send@1.2.1': - dependencies: - '@types/node': 22.19.7 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 22.19.7 - - '@types/stack-utils@2.0.3': {} - '@types/statuses@2.0.6': {} - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 22.19.7 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@types/trusted-types@2.0.7': - optional: true - '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@types/validate-npm-package-name@4.0.2': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.19.7 - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9704,64 +6820,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - optional: true - - '@unrs/resolver-binding-android-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - optional: true + '@vercel/oidc@3.1.0': {} '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -9809,14 +6868,14 @@ snapshots: msw: 2.12.10(@types/node@22.19.7)(typescript@5.9.3) vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0) - '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0))': + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)(terser@5.46.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@24.10.13)(typescript@5.9.3) - vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0) + vite: 5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)(terser@5.46.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -9843,124 +6902,31 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-phases@1.0.4(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - acorn@8.15.0: {} agent-base@7.1.4: {} - ajv-formats@2.1.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 + ai@6.0.105(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.59(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.16(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - - ajv-keywords@5.1.0(ajv@8.17.1): - dependencies: - ajv: 8.17.1 - fast-deep-equal: 3.1.3 - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -9975,12 +6941,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-colors@4.1.3: {} - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -9993,37 +6953,18 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} ansis@4.2.0: {} any-promise@1.3.0: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - append-field@1.0.0: {} - - arg@4.1.3: {} - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 - array-timsort@1.0.3: {} - - asap@2.0.6: {} - assertion-error@2.0.1: {} ast-types@0.16.1: @@ -10042,58 +6983,6 @@ snapshots: transitivePeerDependencies: - debug - babel-jest@30.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.2.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-istanbul@7.0.1: - dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 6.0.3 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@30.2.0: - dependencies: - '@types/babel__core': 7.20.5 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@30.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 30.2.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - bail@2.0.2: {} balanced-match@1.0.2: {} @@ -10102,20 +6991,12 @@ snapshots: dependencies: jackspeak: 4.2.3 - base64-js@1.5.1: {} - baseline-browser-mapping@2.9.19: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -10155,22 +7036,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - - buffer-equal-constant-time@1.0.1: {} - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 + buffer-from@1.1.2: + optional: true bundle-name@4.1.0: dependencies: @@ -10181,10 +7048,6 @@ snapshots: esbuild: 0.27.2 load-tsconfig: 0.2.5 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - bytes@3.1.2: {} cac@6.7.14: {} @@ -10201,10 +7064,6 @@ snapshots: callsites@3.1.0: {} - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - caniuse-lite@1.0.30001770: {} ccount@2.0.1: {} @@ -10224,8 +7083,6 @@ snapshots: chalk@5.6.2: {} - char-regex@1.0.2: {} - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -10234,44 +7091,18 @@ snapshots: character-reference-invalid@2.0.1: {} - chardet@2.1.1: {} - check-error@2.1.3: {} - chevrotain-allstar@0.3.1(chevrotain@11.0.3): - dependencies: - chevrotain: 11.0.3 - lodash-es: 4.17.23 - - chevrotain@11.0.3: - dependencies: - '@chevrotain/cst-dts-gen': 11.0.3 - '@chevrotain/gast': 11.0.3 - '@chevrotain/regexp-to-ast': 11.0.3 - '@chevrotain/types': 11.0.3 - '@chevrotain/utils': 11.0.3 - lodash-es: 4.17.21 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 - chrome-trace-event@1.0.4: {} - - ci-info@4.4.0: {} - - cjs-module-lexer@2.2.0: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 cli-boxes@3.0.0: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - cli-cursor@4.0.0: dependencies: restore-cursor: 4.0.0 @@ -10284,12 +7115,6 @@ snapshots: cli-spinners@3.4.0: {} - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - cli-truncate@5.1.1: dependencies: slice-ansi: 7.1.2 @@ -10303,20 +7128,14 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clone@1.0.4: {} - clsx@2.1.1: {} - co@4.6.0: {} - code-block-writer@13.0.3: {} code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 - collect-v8-coverage@1.0.3: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -10333,31 +7152,15 @@ snapshots: commander@14.0.3: {} - commander@2.20.3: {} + commander@2.20.3: + optional: true commander@4.1.1: {} - commander@7.2.0: {} - commander@8.3.0: {} - comment-json@4.4.1: - dependencies: - array-timsort: 1.0.3 - core-util-is: 1.0.3 - esprima: 4.0.1 - - component-emitter@1.3.1: {} - concat-map@0.0.1: {} - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - confbox@0.1.8: {} consola@3.4.2: {} @@ -10376,32 +7179,11 @@ snapshots: cookie@1.1.1: {} - cookiejar@2.1.4: {} - - core-util-is@1.0.3: {} - cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 - cose-base@1.0.3: - dependencies: - layout-base: 1.0.2 - - cose-base@2.2.0: - dependencies: - layout-base: 2.0.1 - - cosmiconfig@8.3.6(typescript@5.9.3): - dependencies: - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.9.3 - cosmiconfig@9.0.0(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -10411,8 +7193,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-require@1.1.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10435,190 +7215,6 @@ snapshots: csstype@3.2.3: {} - cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): - dependencies: - cose-base: 1.0.3 - cytoscape: 3.33.1 - - cytoscape-fcose@2.2.0(cytoscape@3.33.1): - dependencies: - cose-base: 2.2.0 - cytoscape: 3.33.1 - - cytoscape@3.33.1: {} - - d3-array@2.12.1: - dependencies: - internmap: 1.0.1 - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-axis@3.0.0: {} - - d3-brush@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3-chord@3.0.1: - dependencies: - d3-path: 3.1.0 - - d3-color@3.1.0: {} - - d3-contour@4.0.2: - dependencies: - d3-array: 3.2.4 - - d3-delaunay@6.0.4: - dependencies: - delaunator: 5.0.1 - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-dsv@3.0.1: - dependencies: - commander: 7.2.0 - iconv-lite: 0.6.3 - rw: 1.3.3 - - d3-ease@3.0.1: {} - - d3-fetch@3.0.1: - dependencies: - d3-dsv: 3.0.1 - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-format@3.1.2: {} - - d3-geo@3.1.1: - dependencies: - d3-array: 3.2.4 - - d3-hierarchy@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@1.0.9: {} - - d3-path@3.1.0: {} - - d3-polygon@3.0.1: {} - - d3-quadtree@3.0.1: {} - - d3-random@3.0.1: {} - - d3-sankey@0.12.3: - dependencies: - d3-array: 2.12.1 - d3-shape: 1.3.7 - - d3-scale-chromatic@3.1.0: - dependencies: - d3-color: 3.1.0 - d3-interpolate: 3.0.1 - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-selection@3.0.0: {} - - d3-shape@1.3.7: - dependencies: - d3-path: 1.0.9 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3@7.9.0: - dependencies: - d3-array: 3.2.4 - d3-axis: 3.0.0 - d3-brush: 3.0.0 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - d3-delaunay: 6.0.4 - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-dsv: 3.0.1 - d3-ease: 3.0.1 - d3-fetch: 3.0.1 - d3-force: 3.0.0 - d3-format: 3.1.2 - d3-geo: 3.1.1 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-path: 3.1.0 - d3-polygon: 3.0.1 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - d3-timer: 3.0.1 - d3-transition: 3.0.1(d3-selection@3.0.0) - d3-zoom: 3.0.0 - - dagre-d3-es@7.0.13: - dependencies: - d3: 7.9.0 - lodash-es: 4.17.23 - data-uri-to-buffer@4.0.1: {} data-urls@7.0.0(@noble/hashes@1.8.0): @@ -10628,8 +7224,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - dayjs@1.11.19: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -10655,16 +7249,8 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - define-lazy-prop@3.0.0: {} - delaunator@5.0.1: - dependencies: - robust-predicates: 3.0.2 - delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -10673,27 +7259,14 @@ snapshots: detect-libc@2.1.2: {} - detect-newline@3.1.0: {} - detect-node-es@1.1.0: {} devlop@1.1.0: dependencies: dequal: 2.0.3 - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - diff@4.0.4: {} - diff@8.0.3: {} - dompurify@3.3.1: - optionalDependencies: - '@types/trusted-types': 2.0.7 - dotenv@17.3.1: {} dunder-proto@1.0.1: @@ -10704,10 +7277,6 @@ snapshots: eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - eciesjs@0.4.17: dependencies: '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) @@ -10719,8 +7288,6 @@ snapshots: electron-to-chromium@1.5.286: {} - emittery@0.13.1: {} - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -10750,8 +7317,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.0.0: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10830,20 +7395,6 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - prettier: 3.8.1 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 @@ -10859,11 +7410,6 @@ snapshots: dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -10930,8 +7476,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -10944,8 +7488,6 @@ snapshots: etag@1.8.1: {} - events@3.3.0: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -10979,19 +7521,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - exit-x@0.2.2: {} - expect-type@1.3.0: {} - expect@30.2.0: - dependencies: - '@jest/expect-utils': 30.2.0 - '@jest/get-type': 30.1.0 - jest-matcher-utils: 30.2.0 - jest-message-util: 30.2.0 - jest-mock: 30.2.0 - jest-util: 30.2.0 - express-rate-limit@7.5.1(express@5.2.1): dependencies: express: 5.2.1 @@ -11038,8 +7569,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -11052,18 +7581,12 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-safe-stringify@2.1.1: {} - fast-uri@3.1.0: {} fastq@1.20.1: dependencies: reusify: 1.1.0 - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -11081,15 +7604,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-type@21.3.0: - dependencies: - '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.4 - token-types: 6.1.2 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -11105,11 +7619,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -11135,23 +7644,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.2)): - dependencies: - '@babel/code-frame': 7.29.0 - chalk: 4.1.2 - chokidar: 4.0.3 - cosmiconfig: 8.3.6(typescript@5.9.3) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.4 - tapable: 2.3.0 - typescript: 5.9.3 - webpack: 5.104.1(esbuild@0.27.2) - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -11164,32 +7656,16 @@ snapshots: dependencies: fetch-blob: 3.2.0 - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - forwarded@0.2.0: {} fresh@2.0.0: {} - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 universalify: 2.0.1 - fs-monkey@1.1.0: {} - - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -11222,8 +7698,6 @@ snapshots: get-own-enumerable-keys@1.0.0: {} - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -11248,8 +7722,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -11259,21 +7731,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.0: - dependencies: - minimatch: 10.2.0 - minipass: 7.1.2 - path-scurry: 2.0.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - globals@14.0.0: {} globals@16.5.0: {} @@ -11286,17 +7743,6 @@ snapshots: graphql@16.12.0: {} - hachure-fill@0.5.2: {} - - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -11483,16 +7929,10 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -11502,20 +7942,10 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - imurmurhash@0.1.4: {} indent-string@5.0.0: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ink@6.7.0(@types/react@19.2.14)(react@19.2.4): @@ -11554,10 +7984,6 @@ snapshots: inline-style-parser@0.2.7: {} - internmap@1.0.1: {} - - internmap@2.0.3: {} - ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -11585,8 +8011,6 @@ snapshots: dependencies: get-east-asian-width: 1.4.0 - is-generator-fn@2.1.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -11601,8 +8025,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-interactive@1.0.0: {} - is-interactive@2.0.0: {} is-node-process@1.2.0: {} @@ -11623,8 +8045,6 @@ snapshots: is-stream@4.0.1: {} - is-unicode-supported@0.1.0: {} - is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} @@ -11639,16 +8059,6 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 @@ -11668,8 +8078,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - iterare@1.2.1: {} - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -11680,324 +8088,6 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jest-changed-files@30.2.0: - dependencies: - execa: 5.1.1 - jest-util: 30.2.0 - p-limit: 3.1.0 - - jest-circus@30.2.0: - dependencies: - '@jest/environment': 30.2.0 - '@jest/expect': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.1 - is-generator-fn: 2.1.0 - jest-each: 30.2.0 - jest-matcher-utils: 30.2.0 - jest-message-util: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - p-limit: 3.1.0 - pretty-format: 30.2.0 - pure-rand: 7.0.1 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - jest-util: 30.2.0 - jest-validate: 30.2.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - - jest-config@30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.7 - ts-node: 10.9.2(@types/node@22.19.7)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@30.2.0: - dependencies: - '@jest/diff-sequences': 30.0.1 - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - pretty-format: 30.2.0 - - jest-docblock@30.2.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@30.2.0: - dependencies: - '@jest/get-type': 30.1.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - jest-util: 30.2.0 - pretty-format: 30.2.0 - - jest-environment-node@30.2.0: - dependencies: - '@jest/environment': 30.2.0 - '@jest/fake-timers': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - jest-mock: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - - jest-haste-map@30.2.0: - dependencies: - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 30.0.1 - jest-util: 30.2.0 - jest-worker: 30.2.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@30.2.0: - dependencies: - '@jest/get-type': 30.1.0 - pretty-format: 30.2.0 - - jest-matcher-utils@30.2.0: - dependencies: - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - jest-diff: 30.2.0 - pretty-format: 30.2.0 - - jest-message-util@30.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 30.2.0 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@30.2.0: - dependencies: - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - jest-util: 30.2.0 - - jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): - optionalDependencies: - jest-resolve: 30.2.0 - - jest-regex-util@30.0.1: {} - - jest-resolve-dependencies@30.2.0: - dependencies: - jest-regex-util: 30.0.1 - jest-snapshot: 30.2.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@30.2.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 30.2.0 - jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) - jest-util: 30.2.0 - jest-validate: 30.2.0 - slash: 3.0.0 - unrs-resolver: 1.11.1 - - jest-runner@30.2.0: - dependencies: - '@jest/console': 30.2.0 - '@jest/environment': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - chalk: 4.1.2 - emittery: 0.13.1 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-haste-map: 30.2.0 - jest-leak-detector: 30.2.0 - jest-message-util: 30.2.0 - jest-resolve: 30.2.0 - jest-runtime: 30.2.0 - jest-util: 30.2.0 - jest-watcher: 30.2.0 - jest-worker: 30.2.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@30.2.0: - dependencies: - '@jest/environment': 30.2.0 - '@jest/fake-timers': 30.2.0 - '@jest/globals': 30.2.0 - '@jest/source-map': 30.0.1 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - chalk: 4.1.2 - cjs-module-lexer: 2.2.0 - collect-v8-coverage: 1.0.3 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-mock: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@30.2.0: - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 - '@jest/expect-utils': 30.2.0 - '@jest/get-type': 30.1.0 - '@jest/snapshot-utils': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - expect: 30.2.0 - graceful-fs: 4.2.11 - jest-diff: 30.2.0 - jest-matcher-utils: 30.2.0 - jest-message-util: 30.2.0 - jest-util: 30.2.0 - pretty-format: 30.2.0 - semver: 7.7.4 - synckit: 0.11.12 - transitivePeerDependencies: - - supports-color - - jest-util@30.2.0: - dependencies: - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - chalk: 4.1.2 - ci-info: 4.4.0 - graceful-fs: 4.2.11 - picomatch: 4.0.3 - - jest-validate@30.2.0: - dependencies: - '@jest/get-type': 30.1.0 - '@jest/types': 30.2.0 - camelcase: 6.3.0 - chalk: 4.1.2 - leven: 3.1.0 - pretty-format: 30.2.0 - - jest-watcher@30.2.0: - dependencies: - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.7 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 30.2.0 - string-length: 4.0.2 - - jest-worker@27.5.1: - dependencies: - '@types/node': 22.19.7 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest-worker@30.2.0: - dependencies: - '@types/node': 22.19.7 - '@ungap/structured-clone': 1.3.0 - jest-util: 30.2.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - '@jest/types': 30.2.0 - import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jiti@2.6.1: {} jose@6.1.3: {} @@ -12006,11 +8096,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -12054,42 +8139,18 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} - jsonc-parser@3.3.1: {} - jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.4 - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - katex@0.16.28: dependencies: commander: 8.3.0 @@ -12098,26 +8159,10 @@ snapshots: dependencies: json-buffer: 3.0.1 - khroma@2.1.0: {} - kleur@3.0.3: {} kleur@4.1.5: {} - langium@3.3.1: - dependencies: - chevrotain: 11.0.3 - chevrotain-allstar: 0.3.1(chevrotain@11.0.3) - vscode-languageserver: 9.0.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.0.8 - - layout-base@1.0.2: {} - - layout-base@2.0.1: {} - - leven@3.1.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -12170,54 +8215,19 @@ snapshots: lightningcss-linux-x64-gnu: 1.30.2 lightningcss-linux-x64-musl: 1.30.2 lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-esm@1.0.3: {} - - load-tsconfig@0.2.5: {} - - loader-runner@4.3.1: {} - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash-es@4.17.21: {} - - lodash-es@4.17.23: {} - - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - - lodash.memoize@4.1.2: {} + lightningcss-win32-x64-msvc: 1.30.2 - lodash.merge@4.6.2: {} + lilconfig@3.1.3: {} - lodash.once@4.1.1: {} + lines-and-columns@1.2.4: {} - lodash@4.17.23: {} + load-tsconfig@0.2.5: {} - log-symbols@4.1.0: + locate-path@6.0.0: dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} log-symbols@6.0.0: dependencies: @@ -12240,10 +8250,6 @@ snapshots: dependencies: react: 19.2.4 - magic-string@0.30.17: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -12258,16 +8264,8 @@ snapshots: dependencies: semver: 7.7.4 - make-error@1.3.6: {} - - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - markdown-table@3.0.4: {} - marked@16.4.2: {} - marked@17.0.1: {} math-intrinsics@1.1.0: {} @@ -12439,45 +8437,14 @@ snapshots: mdn-data@2.12.2: {} - media-typer@0.3.0: {} - media-typer@1.1.0: {} - memfs@3.5.3: - dependencies: - fs-monkey: 1.1.0 - merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} merge2@1.4.1: {} - mermaid@11.12.2: - dependencies: - '@braintree/sanitize-url': 7.1.2 - '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 0.6.3 - '@types/d3': 7.4.3 - cytoscape: 3.33.1 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) - cytoscape-fcose: 2.2.0(cytoscape@3.33.1) - d3: 7.9.0 - d3-sankey: 0.12.3 - dagre-d3-es: 7.0.13 - dayjs: 1.11.19 - dompurify: 3.3.1 - katex: 0.16.28 - khroma: 2.1.0 - lodash-es: 4.17.23 - marked: 16.4.2 - roughjs: 4.6.6 - stylis: 4.3.6 - ts-dedent: 2.2.0 - uuid: 11.1.0 - - methods@1.1.2: {} - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -12728,8 +8695,6 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@2.6.0: {} - mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -12750,10 +8715,6 @@ snapshots: minipass@7.1.2: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -12814,16 +8775,6 @@ snapshots: transitivePeerDependencies: - '@types/node' - multer@2.0.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - mute-stream@2.0.0: {} mz@2.7.0: @@ -12834,39 +8785,25 @@ snapshots: nanoid@3.3.11: {} - napi-postinstall@0.3.4: {} - natural-compare@1.4.0: {} negotiator@1.0.0: {} - neo-async@2.6.2: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - node-abort-controller@3.1.1: {} - node-domexception@1.0.0: {} - node-emoji@1.11.0: - dependencies: - lodash: 4.17.23 - node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-int64@0.4.0: {} - node-releases@2.0.27: {} - normalize-path@3.0.0: {} - npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -12929,18 +8866,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - ora@8.2.0: dependencies: chalk: 5.6.2 @@ -12955,24 +8880,14 @@ snapshots: outvariant@1.4.3: {} - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -13014,12 +8929,8 @@ snapshots: path-browserify@1.0.1: {} - path-data-parser@0.1.0: {} - path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -13029,17 +8940,10 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-scurry@2.0.1: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.2 - path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} - path-type@4.0.0: {} - pathe@1.1.2: {} pathe@2.0.3: {} @@ -13050,33 +8954,18 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} - picomatch@4.0.3: {} pirates@4.0.7: {} pkce-challenge@5.0.1: {} - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - pkg-types@1.3.1: dependencies: confbox: 0.1.8 mlly: 1.8.0 pathe: 2.0.3 - pluralize@8.0.0: {} - - points-on-curve@0.2.0: {} - - points-on-path@0.2.1: - dependencies: - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 @@ -13101,18 +8990,8 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: - dependencies: - fast-diff: 1.3.0 - prettier@3.8.1: {} - pretty-format@30.2.0: - dependencies: - '@jest/schemas': 30.0.5 - ansi-styles: 5.2.0 - react-is: 18.3.1 - pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -13133,8 +9012,6 @@ snapshots: punycode@2.3.1: {} - pure-rand@7.0.1: {} - qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -13204,10 +9081,6 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@3.0.2: @@ -13222,8 +9095,6 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-is@18.3.1: {} - react-reconciler@0.33.0(react@19.2.4): dependencies: react: 19.2.4 @@ -13274,12 +9145,6 @@ snapshots: react@19.2.4: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} recast@0.23.11: @@ -13290,8 +9155,6 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - reflect-metadata@0.2.2: {} - regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -13396,21 +9259,12 @@ snapshots: require-from-string@2.0.2: {} - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -13427,8 +9281,6 @@ snapshots: robots-parser@3.0.1: {} - robust-predicates@3.0.2: {} - rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -13460,13 +9312,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 - roughjs@4.6.6: - dependencies: - hachure-fill: 0.5.2 - path-data-parser: 0.1.0 - points-on-curve: 0.2.0 - points-on-path: 0.2.1 - router@2.2.0: dependencies: debug: 4.4.3 @@ -13483,18 +9328,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rw@1.3.3: {} - - rxjs@7.8.1: - dependencies: - tslib: 2.8.1 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} saxes@6.0.0: @@ -13503,19 +9336,6 @@ snapshots: scheduler@0.27.0: {} - schema-utils@3.3.0: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - schema-utils@4.3.3: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) - semver@6.3.1: {} semver@7.7.4: {} @@ -13536,10 +9356,6 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -13650,8 +9466,6 @@ snapshots: sisteransi@1.0.5: {} - slash@3.0.0: {} - slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -13664,26 +9478,18 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + optional: true source-map@0.6.1: {} - source-map@0.7.4: {} - source-map@0.7.6: {} space-separated-tokens@2.0.2: {} - sprintf-js@1.0.3: {} - stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -13717,15 +9523,8 @@ snapshots: transitivePeerDependencies: - supports-color - streamsearch@1.1.0: {} - strict-event-emitter@0.5.1: {} - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -13749,10 +9548,6 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13774,18 +9569,12 @@ snapshots: strip-bom@3.0.0: {} - strip-bom@4.0.0: {} - strip-final-newline@2.0.0: {} strip-final-newline@4.0.0: {} strip-json-comments@3.1.1: {} - strtok3@10.3.4: - dependencies: - '@tokenizer/token': 0.3.0 - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -13794,8 +9583,6 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylis@4.3.6: {} - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -13806,44 +9593,12 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 - superagent@10.3.0: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 3.5.4 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.1 - transitivePeerDependencies: - - supports-color - - supertest@7.2.2: - dependencies: - cookie-signature: 1.2.2 - methods: 1.1.2 - superagent: 10.3.0 - transitivePeerDependencies: - - supports-color - supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - symbol-observable@4.0.0: {} - symbol-tree@3.2.4: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - tagged-tag@1.0.0: {} tailwind-merge@3.4.0: {} @@ -13854,29 +9609,13 @@ snapshots: terminal-size@4.0.1: {} - terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.46.0 - webpack: 5.104.1(esbuild@0.27.2) - optionalDependencies: - esbuild: 0.27.2 - terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 + optional: true test-exclude@7.0.1: dependencies: @@ -13917,20 +9656,12 @@ snapshots: dependencies: tldts-core: 7.0.23 - tmpl@1.0.5: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} - token-types@6.1.2: - dependencies: - '@borewit/text-codec': 0.2.1 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - toml@3.0.0: {} tough-cookie@6.0.0: @@ -13951,75 +9682,17 @@ snapshots: dependencies: typescript: 5.9.3 - ts-dedent@2.2.0: {} - ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 30.2.0(@types/node@22.19.7)(ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.29.0) - esbuild: 0.27.2 - jest-util: 30.2.0 - - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.2)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.19.0 - micromatch: 4.0.8 - semver: 7.7.4 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.104.1(esbuild@0.27.2) - ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 code-block-writer: 13.0.3 - ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.7 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 - tsconfig-paths-webpack-plugin@4.2.0: - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.19.0 - tapable: 2.3.0 - tsconfig-paths: 4.2.0 - tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -14073,29 +9746,16 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} - type-fest@5.4.4: dependencies: tagged-tag: 1.0.0 - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - type-is@2.0.1: dependencies: content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.2 - typedarray@0.0.6: {} - typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -14111,15 +9771,6 @@ snapshots: ufo@1.6.3: {} - uglify-js@3.19.3: - optional: true - - uid@2.0.2: - dependencies: - '@lukeed/csprng': 1.1.0 - - uint8array-extras@1.5.0: {} - undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -14177,30 +9828,6 @@ snapshots: unpipe@1.0.0: {} - unrs-resolver@1.11.1: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - until-async@3.0.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -14234,16 +9861,6 @@ snapshots: util-deprecate@1.0.2: {} - uuid@11.1.0: {} - - v8-compile-cache-lib@3.0.1: {} - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - validate-npm-package-name@7.0.2: {} vary@1.1.2: {} @@ -14299,12 +9916,12 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0)): + vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -14331,6 +9948,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.46.0 + vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.7 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -14387,7 +10021,7 @@ snapshots: vitest@2.1.9(@types/node@24.10.13)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.7)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@24.10.13)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)(terser@5.46.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -14420,82 +10054,16 @@ snapshots: - supports-color - terser - vscode-jsonrpc@8.2.0: {} - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-uri@3.0.8: {} - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - - watchpack@2.5.1: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} webidl-conversions@8.0.1: {} - webpack-node-externals@3.0.0: {} - - webpack-sources@3.3.3: {} - - webpack@5.104.1(esbuild@0.27.2): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 - es-module-lexer: 2.0.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2)) - watchpack: 2.5.1 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1(@noble/hashes@1.8.0): @@ -14525,8 +10093,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -14553,11 +10119,6 @@ snapshots: wrappy@1.0.2: {} - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - ws@8.19.0: {} wsl-utils@0.3.1: @@ -14569,8 +10130,6 @@ snapshots: xmlchars@2.2.0: {} - xtend@4.0.2: {} - y18n@5.0.8: {} yallist@3.1.1: {} @@ -14589,8 +10148,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: {} - yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} diff --git a/site/components/memo-architecture-remotion.tsx b/site/components/memo-architecture-remotion.tsx index 17da91f..c970119 100644 --- a/site/components/memo-architecture-remotion.tsx +++ b/site/components/memo-architecture-remotion.tsx @@ -333,7 +333,7 @@ export function MemoArchitectureDiagram() { gap: 20, }} > - + Server (commands / queries)** + - create session: `POST /api/chat/sessions` + - send message: `POST /api/chat/sessions/{id}/messages` + - approval decision: `POST /api/chat/sessions/{id}/approval` + - read state / history / admin resources: normal HTTP endpoints -With REST, this becomes cumbersome: +- **Server -> Client (live events)** + - subscribe: `GET /api/chat/sessions/{id}/events` + - transport: `text/event-stream` + - payload: normalized event envelope (`event`, `data`, `seq`, `ts`) -- Streaming output → requires polling or SSE -- Approval interception → same, polling or checking status then judging -- Status synchronization → also polling +## Event Envelope and Lifecycle Events -Three separate mechanisms to maintain. That'"'"'s a lot of overhead. - -## WebSocket Is a Ready-Made Answer - -After thinking it through, WebSocket is exactly designed for this scenario: - -- **Full-duplex**: Server can push actively, browser can send at any time -- **Low latency**: Connection established once, then all frame transfers -- **Stateful**: One connection corresponds to one session, semantics are clear - -But having the protocol isn'"'"'t enough—the key is how to use it. - -## Frame Design: Simplified JSON-RPC - -memo uses a JSON-RPC-like frame format—not complicated, just three types: +SSE frames are sent as named events, while `data` carries a structured envelope: ```typescript -// Client sends request -{ id: "uuid", type: "rpc.request", method: "session.create", params: {...} } - -// Server responds -{ id: "uuid", type: "rpc.response", ok: true, data: {...} } - -// Server pushes proactively -{ type: "event", topic: "assistant.chunk", data: {...}, seq: 1, ts: "..." } +event: assistant.chunk +data: { + "event": "assistant.chunk", + "data": { "turn": 3, "step": 1, "chunk": "hello" }, + "seq": 42, + "ts": "2026-03-02T12:34:56.000Z" +} ``` -**RPC calls** handle "request/response": client sends one, server processes and returns one. For example: creating sessions, sending messages, querying status. - -**Event pushes** handle "server-initiated": subscribe to a topic, then wait for the server to push data to that topic. For example: model started outputting tokens, tool execution completed, approval needed. - -These two patterns combined can cover almost all communication scenarios. - -## Event Types: Covering the Complete Agent Lifecycle - -memo'"'"'s WebSocket pushes these events: +Main events cover the complete agent turn lifecycle: +- `session.snapshot`: initial state right after subscription - `turn.start`: User sent a message - `assistant.chunk`: Model output a token - `turn.final`: This turn of conversation ended - `session.status`: Session status changed (idle / running / closed) +- `context.usage` / `context.compact`: context window updates - `approval.request`: A dangerous operation needs user confirmation - `tool.action`: A tool started running - `tool.observation`: Tool finished, returning results +- `system.message` / `error`: runtime notices and failures -These events strung together represent the complete lifecycle of an Agent from "receiving requirements" to "finishing work". - -## Client Implementation: Reconnection, Timeout, Subscription Management - -The browser'"'"'s ws-client (`packages/web-ui/src/api/ws-client.ts`) has several key designs: - -**Auto-reconnection**: Network instability is common. When disconnected, silently reconnect without requiring user manual refresh. +## Client Implementation Notes -**Request timeout**: Default 20 seconds. Outgoing requests can'"'"'t wait indefinitely—timeout clears the pending state to prevent freezing. +The current browser implementation is in `packages/web-ui/src/stores/chat-store.ts`, with three practical safeguards: -**Subscription management**: A topic can have multiple handlers. Unsubscribing returns a small function, which is very convenient to use: +1. **Token-aware connect** + - Refresh/access token is prepared before opening stream. + - `401` stops reconnect and asks user to login again. -```typescript -const unsub = wsClient.subscribe('assistant.chunk', (data) => { - appendToChat(data.chunk) -}) -// When done -unsub() -``` +2. **Reconnect with backoff** + - Broken stream schedules reconnect. + - Delay grows gradually and is capped to avoid retry storms. -**Authentication**: Pass accessToken via URL query, server validates during WebSocket upgrade stage. +3. **Incremental SSE parser** + - Reads chunks from `ReadableStream`. + - Splits frames with `\n\n`. + - Accepts both named SSE events and envelope JSON payloads. -## Why All-in +## Server Implementation Notes -Simply put, three reasons: +On the server side: -**1. Real-time capability.** Streaming output, approval interception, status synchronization—all require "server active push". HTTP can'"'"'t handle this, either polling or SSE, but maintenance cost is high. +- Route registration in `packages/core/src/server/http_server.ts` + - `GET /api/chat/sessions/:id/events` authenticates first + - subscribes through `SseHub` + - immediately emits `session.snapshot` -**2. Unification.** One connection packages all communication: session management, chat, tool calls, approval. No other HTTP APIs. Unified protocol, unified debugging. +- SSE runtime in `packages/core/src/server/sse.ts` + - sets SSE headers (`text/event-stream`, `keep-alive`, no buffering) + - heartbeat comments every 20s (`: keep-alive`) + - per-session sequence id (`seq`) for ordered replay/debugging -**3. Low overhead.** Every HTTP request needs TLS handshake + TCP establishment, high latency. WebSocket establishes once, then all frames. In conversational interaction, the experience difference is obvious. +## Why This Split Works Better Now -## Security and Fault Tolerance +Three reasons: -WebSocket also needs protection: +1. **Clear responsibility boundaries** + - Commands stay in typed HTTP handlers. + - Streaming stays in a single SSE channel. -```typescript -// Server rate limiting -const MAX_REQUESTS_PER_MINUTE = 120 -const MAX_REQUEST_BYTES = 256 * 1024 -``` +2. **Operational simplicity** + - Easier to inspect with browser devtools/curl. + - Fewer custom RPC semantics to maintain. -Disconnection should also give clear error codes: +3. **Frontend robustness** + - Reconnect, auth handling, and event parsing are explicit in one store. + - No hidden coupling to a custom websocket client abstraction. -- 4401: Invalid token -- 4404: Session does not exist -- 4409: Session was taken over by another client +The goal is not "never use WebSocket", but to keep the default path minimal and reliable for memo's chat workflow. ## Summary -memo'"'"'s Web communication layer chose All-in WebSocket, core reason: **real-time capability + unified protocol + low overhead**. - -One connection, RPC for request-response, event subscription for server push. Suitable for Agent applications with strong interactivity and high real-time requirements. +memo Web now uses **HTTP API + SSE** as the primary communication contract. -If it'"'"'s pure request-response with low real-time requirements, REST is also a good choice—simplicity is king. +- Request/response: regular HTTP routes +- Real-time updates: session event stream +- Shared model: typed event envelopes across server and client -(EOF) +This architecture aligns with the refactored core server and removes stale dependencies on deleted websocket client code. diff --git a/site/content/blog/en/why-memo-web-version.mdx b/site/content/blog/en/why-memo-web-version.mdx index 4aa9812..37e7a2f 100644 --- a/site/content/blog/en/why-memo-web-version.mdx +++ b/site/content/blog/en/why-memo-web-version.mdx @@ -55,7 +55,7 @@ This was a difficult problem for the original implementation. So I made many cha - **Workspace abstraction**: The core package defines workspace data structure, including fields like id, name, cwd (current working directory), creation time, last used time. Using `workspaceIdFromCwd` with SHA256 hashing on the path generates a stable workspace ID. -- **Multi-workspace support**: web-server'"'"'s `SessionRuntimeRegistry` maintains session and workspace mappings. Each session can be associated with a workspace, with status (idle/running/closed) tracked independently. +- **Multi-workspace support**: core server'"'"'s `SessionManager` and runtime badge APIs maintain session and workspace mappings. Each session can be associated with a workspace, with status (idle/running/closed) tracked independently. - **Unified into core**: Consolidating TUI implementation into core, so we don'"'"'t need to maintain separate code for different endpoints. @@ -67,14 +67,13 @@ The premise of "Add project" is: during remote access, the file explorer reads t This is completely different from local desktop applications—the browser runs on the client, but file operations happen on the server. -web-server'"'"'s `WorkspacesService` implements this: +The core HTTP server implements this through workspace routes and filesystem listing: ```typescript -async listDirectories(pathInput: string | undefined): Promise { - // Read the directory of the machine running memo - const entries = await readdir(targetPath, { withFileTypes: true }); - // Return to browser for display - return { path, parentPath, items }; +async function listWorkspaceDirectories(workspaces, pathInput) { + // Read directories from the machine running memo + // and keep results inside allowed workspace roots. + return { path, parentPath, items } } ``` diff --git a/site/content/blog/zh/web-websocket-design.mdx b/site/content/blog/zh/web-websocket-design.mdx index f5c887f..9ed3775 100644 --- a/site/content/blog/zh/web-websocket-design.mdx +++ b/site/content/blog/zh/web-websocket-design.mdx @@ -1,136 +1,120 @@ --- -title: 'Web 与 Server 的统一通信架构:All-in WebSocket 设计实践' -description: '从 memo 的实践中提炼出 WebSocket 通信方案:JSON-RPC 帧设计、事件订阅模型、为什么选择全栈 WebSocket 以及在实际场景中的优势。' +title: 'Web 与 Server 的统一通信架构:HTTP API + SSE 设计实践' +description: '在本轮重构后,memo Web 采用 HTTP API 处理请求响应,采用 SSE 处理服务端推送。本文总结事件模型、前后端实现和迁移经验。' date: '2026-02-18' order: 5 --- -# Web 与 Server 的统一通信架构:All-in WebSocket 设计实践 +# Web 与 Server 的统一通信架构:HTTP API + SSE 设计实践 -把 TUI 做完后,做 Web 版是顺理成章的事。但真正动手的时候,第一个问题就把的我卡住了: +把 TUI 做完后,做 Web 版是顺理成章的事。通信层仍然是第一个关键问题: **通信层怎么搞?** -## 上来就被问住了 +在最新重构里,memo 最终落地为一个非常明确的拆分: -TUI 版本不存在通信问题——所有逻辑都在同一个进程里,函数直接调用就行。但 Web 版不一样,浏览器和服务器是两个独立的进程,得通过网络说话。 +- **HTTP API** 负责请求/响应 +- **SSE 事件流** 负责服务端主动推送 -一开始我想都没想:那就 REST API 呗。正好我搞前端也熟,做起来应该顺手。 +这套口径已经替代了早期单通道 WebSocket 叙事,并与当前代码实现一致。 -但等真的把需求列完,我开始觉得不对劲了: +## 通信拆分:命令通道 vs 事件通道 -- 流式输出:模型是一个字一个字吐的,界面得跟着刷新 -- 审批拦截:执行危险操作时,浏览器得弹窗让用户确认 -- 状态同步:会话 running 还是 idle,界面得实时知道 -- 请求响应:创建会话、发送消息、查询状态…… +把方向拆开后,场景就很清晰: -这些场景都有一个共同点:**服务端需要主动推送到浏览器**。 +- **Client -> Server(命令/查询)** + - 创建会话:`POST /api/chat/sessions` + - 发送消息:`POST /api/chat/sessions/{id}/messages` + - 审批决策:`POST /api/chat/sessions/{id}/approval` + - 查询状态/历史/管理接口:常规 HTTP -如果是 REST,就很麻烦: +- **Server -> Client(实时事件)** + - 订阅入口:`GET /api/chat/sessions/{id}/events` + - 传输协议:`text/event-stream` + - 数据结构:统一事件 envelope(`event`、`data`、`seq`、`ts`) -- 流式输出 → 得用轮询,或者 SSE -- 审批拦截 → 同样得轮询,或者轮询状态再判断 -- 状态同步 → 也是轮询 +## 事件 Envelope 与生命周期事件 -三套机制,光维护就够喝一壶。 - -## WebSocket 是个现成答案 - -想了一圈,WebSocket 正好就是为这种场景设计的: - -- 全双工:服务端可以主动推,浏览器也可以随时发 -- 低延迟:一次连接建立,之后全是帧传输 -- 有状态:一个连接对应一个会话,语义清晰 - -但光有协议还不够,关键是怎么用。 - -## 帧设计:JSON-RPC 的简化版 - -memo 用了一套类似 JSON-RPC 的帧格式,不复杂,三种就够了: +SSE 帧通过事件名分发,`data` 内承载结构化 envelope: ```typescript -// 客户端发请求 -{ id: "uuid", type: "rpc.request", method: "session.create", params: {...} } - -// 服务端返回 -{ id: "uuid", type: "rpc.response", ok: true, data: {...} } - -// 服务端主动推 -{ type: "event", topic: "assistant.chunk", data: {...}, seq: 1, ts: "..." } +event: assistant.chunk +data: { + "event": "assistant.chunk", + "data": { "turn": 3, "step": 1, "chunk": "hello" }, + "seq": 42, + "ts": "2026-03-02T12:34:56.000Z" +} ``` -**RPC 调用**用来做"请求/响应":客户端发一个,服务端处理完返回一个。比如创建会话、发送消息、查询状态。 - -**事件推送**用来做"服务端主动":订阅一个 topic,然后等服务端往这个 topic 推数据。比如模型开始吐字了、工具执行完了、该审批了。 - -这两种模式加起来,几乎能覆盖所有通信场景。 - -## 事件类型:覆盖 Agent 完整生命周期 - -memo 的 WebSocket 会推这些事件: +核心事件覆盖了 Agent 一轮执行的完整生命周期: +- `session.snapshot`:订阅后先下发一次当前快照 - `turn.start`:用户发来一条消息 - `assistant.chunk`:模型吐了一个字/词 - `turn.final`:这轮对话结束 - `session.status`:会话状态变了(idle / running / closed) +- `context.usage` / `context.compact`:上下文窗口变化 - `approval.request`:有个危险操作需要用户确认 - `tool.action`:某个工具开始跑了 - `tool.observation`:工具跑完了,返回结果 +- `system.message` / `error`:系统提示和错误 -这些事件串起来,就是一个 Agent 从"接需求"到"干完活"的完整生命周期。 - -## 客户端实现:重连、超时、订阅管理 - -浏览器端的 ws-client(`packages/web-ui/src/api/ws-client.ts`)做了几个关键设计: - -**自动重连**:网络不稳定是常态,断掉就默默重连,不用用户手动刷新。 +## 客户端实现要点 -**请求超时**:默认 20 秒。发出去的请求不能无限等,超时就把 pending 状态清掉,防止卡死。 +浏览器端当前实现位于 `packages/web-ui/src/stores/chat-store.ts`,关键点有三类: -**订阅管理**:一个 topic 可以有多个 handler,取消订阅返回一个小函数,用起来很顺: +1. **带鉴权的连接建立** + - 打开流前先准备 access token + - 返回 `401` 时停止重连并提示重新登录 -```typescript -const unsub = wsClient.subscribe('assistant.chunk', (data) => { - appendToChat(data.chunk) -}) -// 不用了 -unsub() -``` +2. **指数退避重连** + - 流中断后自动安排重连 + - 延迟逐步增加并设上限,避免重试风暴 -**认证**:通过 URL query 传 accessToken,服务端在 WebSocket 升级阶段验证。 +3. **增量 SSE 解析** + - 从 `ReadableStream` 持续读 chunk + - 用 `\n\n` 切帧 + - 同时兼容 named event 与 envelope JSON -## 为什么 All-in +## 服务端实现要点 -说白了,就三个原因: +服务端对应实现: -**一是实时性。** 流式输出、审批拦截、状态同步,个个都需要"服务端主动推"。HTTP 搞不定这个,非得轮询或者 SSE,但维护成本高。 +- 路由定义在 `packages/core/src/server/http_server.ts` + - `GET /api/chat/sessions/:id/events` 先鉴权 + - 通过 `SseHub` 注册连接 + - 订阅成功后立即推送 `session.snapshot` -**二是统一。** 一次连接把所有通信都包了:会话管理、聊天、工具调用、审批。没有其他 HTTP API。协议统一,调试也统一。 +- SSE 基础设施在 `packages/core/src/server/sse.ts` + - 设置标准 SSE 头(`text/event-stream`、`keep-alive`、禁缓冲) + - 每 20 秒发送心跳注释(`: keep-alive`) + - 按 session 维护递增序号 `seq` 方便排障 -**三是低开销。** HTTP 每次请求都要 TLS 握手 + TCP 建立,延迟高。WebSocket 建一次,之后全是帧。对话式交互里体验差别很明显。 +## 为什么现在这套更合适 -## 安全与容错 +核心是三个点: -WebSocket 也需要保护: +1. **职责边界清晰** + - 命令接口走类型化 HTTP handler + - 实时更新集中在单一 SSE 流 -```typescript -// 服务端限流 -const MAX_REQUESTS_PER_MINUTE = 120 -const MAX_REQUEST_BYTES = 256 * 1024 -``` +2. **运维与调试更简单** + - 浏览器网络面板和 `curl` 就能直接观察 + - 不再维护额外的自定义 RPC 协议层 -断连也要给清晰的提示: +3. **前端韧性更高** + - 重连、鉴权、事件解析都在同一 store 显式处理 + - 不依赖已删除的 websocket client 抽象 -- 4401:token 无效 -- 4404:会话不存在 -- 4409:会话被其他客户端抢走了 +目标不是“永远不用 WebSocket”,而是让默认链路在当前聊天场景下足够简单、稳定、可维护。 ## 总结 -memo 的 Web 通信层选 All-in WebSocket,核心就一个原因:**实时性 + 统一协议 + 低开销**。 - -一套连接,RPC 做请求响应,事件订阅做服务端推送。适合交互性强、实时性要求高的 Agent 应用。 +memo Web 当前主通信口径是 **HTTP API + SSE**: -如果是纯请求响应、实时性要求不高的场景,REST 也是好选择——简单就是硬道理。 +- 请求响应:常规 HTTP 路由 +- 实时事件:会话级 SSE 流 +- 数据模型:前后端共享的事件 envelope -(完) +这套架构已经和重构后的 core server 实现对齐,也清除了对已删除 websocket client 的历史引用。 diff --git a/site/content/blog/zh/why-memo-web-version.mdx b/site/content/blog/zh/why-memo-web-version.mdx index 9527b04..99b5dd9 100644 --- a/site/content/blog/zh/why-memo-web-version.mdx +++ b/site/content/blog/zh/why-memo-web-version.mdx @@ -55,7 +55,7 @@ cd ~/Desktop/projects/project_a/ && memo - **workspace 抽象**:核心包定义了 workspace 的数据结构,包括 id、name、cwd(当前工作目录)、创建时间、最后使用时间等字段。通过 `workspaceIdFromCwd` 用 SHA256 对路径做哈希,生成稳定的 workspace ID。 -- **多 workspace 支持**:web-server 的 `SessionRuntimeRegistry` 维护了 session 和 workspace 的映射关系。每个 session 可以关联一个 workspace,状态(idle/running/closed)也独立追踪。 +- **多 workspace 支持**:core server 的 `SessionManager` 与 runtime badge API 维护了 session 和 workspace 的映射关系。每个 session 可以关联一个 workspace,状态(idle/running/closed)也独立追踪。 - **统一到 core**:把 TUI 的实现收拢到 core 中,这样就不必为不同端各维护一套代码。 @@ -67,14 +67,13 @@ cd ~/Desktop/projects/project_a/ && memo 这和本地桌面应用完全不一样——浏览器跑在客户端,但文件操作发生在服务端。 -web-server 的 `WorkspacesService` 实现了这个功能: +core HTTP server 通过 workspace 路由与文件系统列表接口实现了这个能力: ```typescript -async listDirectories(pathInput: string | undefined): Promise { - // 读取运行 memo 的那台机器的目录 - const entries = await readdir(targetPath, { withFileTypes: true }); - // 返回给浏览器展示 - return { path, parentPath, items }; +async function listWorkspaceDirectories(workspaces, pathInput) { + // 读取运行 memo 的那台机器目录 + // 并确保结果始终限制在允许的 workspace 根路径内 + return { path, parentPath, items } } ``` diff --git a/tsup.config.ts b/tsup.config.ts index c0a39fb..4452bdf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,43 +1,7 @@ import { defineConfig } from 'tsup' -import { build as esbuildBuild } from 'esbuild' -import { copyFileSync, cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { copyFileSync, cpSync, existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' -async function bundleWebServerForDist(webServerDist: string, outputDir: string): Promise { - const entryFile = join(webServerDist, 'main.js') - if (!existsSync(entryFile)) { - console.warn('! web-server main.js not found, skipped bundling dist/web/server/main.cjs') - return - } - - mkdirSync(outputDir, { recursive: true }) - await esbuildBuild({ - entryPoints: [entryFile], - outfile: join(outputDir, 'main.cjs'), - bundle: true, - minify: false, - sourcemap: false, - platform: 'node', - format: 'cjs', - target: 'node20', - legalComments: 'none', - external: [ - '@dqbd/tiktoken', - 'class-transformer', - 'class-validator', - '@nestjs/websockets', - '@nestjs/websockets/socket-module', - '@nestjs/microservices', - '@nestjs/microservices/microservices-module', - ], - }) - writeFileSync( - join(outputDir, 'package.json'), - `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`, - 'utf8', - ) -} - export default defineConfig({ entry: { index: 'packages/tui/src/cli.tsx', @@ -65,17 +29,11 @@ export default defineConfig({ cpSync(join('packages/tui/src/task-prompts'), join('dist/task-prompts'), { recursive: true, }) - const webServerDist = join('packages/web-server/dist') - if (existsSync(webServerDist)) { - const bundledServerDist = join('dist/web/server') - mkdirSync(join('dist/web'), { recursive: true }) - await bundleWebServerForDist(webServerDist, bundledServerDist) - } const webUiDist = join('packages/web-ui/dist') if (existsSync(webUiDist)) { mkdirSync(join('dist/web'), { recursive: true }) cpSync(webUiDist, join('dist/web/ui'), { recursive: true }) - console.log('✓ Copied prompt.md, task prompts, and web server/ui assets to dist/') + console.log('✓ Copied prompt.md, task prompts, and web UI assets to dist/') return } console.log('✓ Copied prompt.md and task prompts to dist/') From a8a0a3468b198a8c8aa93440d8626992f42972bc Mon Sep 17 00:00:00 2001 From: minorcell Date: Wed, 4 Mar 2026 17:40:51 +0800 Subject: [PATCH 2/2] reafctor: core server --- docs/core.md | 4 +- docs/model-agnostic-design.md | 8 +- docs/npm-distribution-design.md | 2 +- package.json | 1 + packages/core/package.json | 3 +- packages/core/src/index.ts | 13 +- .../{ => agent}/compact_prompt.test.ts | 2 +- .../src/runtime/{ => agent}/compact_prompt.ts | 0 .../src/runtime/{ => agent}/hooks.test.ts | 2 +- .../core/src/runtime/{ => agent}/hooks.ts | 0 .../runtime/{ => agent}/session_hooks.test.ts | 2 +- .../runtime/{ => agent}/session_runtime.ts | 6 +- .../session_runtime_helpers.test.ts | 2 +- .../{ => agent}/session_runtime_helpers.ts | 0 .../index.test.ts} | 0 .../runtime/{history.ts => history/index.ts} | 0 .../indexer.test.ts} | 2 +- .../{history_index.ts => history/indexer.ts} | 6 +- .../parser.test.ts} | 2 +- .../{history_parser.ts => history/parser.ts} | 4 +- .../{mcp_admin.test.ts => mcp/admin.test.ts} | 2 +- .../runtime/{mcp_admin.ts => mcp/admin.ts} | 2 +- .../profile.test.ts} | 2 +- .../{model_profile.ts => model/profile.ts} | 0 .../{prompt.test.ts => prompt/index.test.ts} | 2 +- .../runtime/{prompt.ts => prompt/index.ts} | 0 .../src/runtime/{ => prompt}/memory.test.ts | 0 .../core/src/runtime/{ => prompt}/prompt.md | 0 .../runtime/{ => session}/defaults.test.ts | 5 +- .../src/runtime/{ => session}/defaults.ts | 0 .../defaults.with_default_deps.test.ts | 16 +- .../runtime/{session.ts => session/index.ts} | 6 +- .../admin.test.ts} | 4 +- .../{skills_admin.ts => skills/admin.ts} | 6 +- .../{skills.test.ts => skills/index.test.ts} | 0 .../runtime/{skills.ts => skills/index.ts} | 0 packages/core/src/runtime/slash/index.ts | 2 - .../core/src/runtime/slash/registry.test.ts | 175 ------ packages/core/src/runtime/slash/registry.ts | 249 -------- packages/core/src/runtime/slash/types.ts | 51 -- .../{ => workspace}/file_suggestions.test.ts | 0 .../{ => workspace}/file_suggestions.ts | 0 .../index.test.ts} | 2 +- .../{workspace.ts => workspace/index.ts} | 0 .../src/server/handler/session_manager.ts | 2 +- packages/core/src/server/http_server.test.ts | 85 +++ packages/core/src/server/process_entry.ts | 199 +++++++ packages/core/src/server/router/api_routes.ts | 310 +++++++++- packages/core/src/server/router/openapi.ts | 8 + packages/core/src/server/utils/http.ts | 4 +- packages/core/src/web/types.ts | 316 +---------- packages/tui/package.json | 2 +- packages/tui/src/App.tsx | 102 ++-- packages/tui/src/bottom_pane/Composer.tsx | 30 +- packages/tui/src/cli.tsx | 177 +++--- .../tui/src/controllers/file_suggestions.ts | 30 +- .../src/controllers/history_parser.test.ts | 94 ++-- .../tui/src/controllers/history_parser.ts | 15 +- .../src/controllers/session_history.test.ts | 150 +++-- .../tui/src/controllers/session_history.ts | 76 +-- packages/tui/src/http/api_types.ts | 192 +++++++ packages/tui/src/http/core_server_client.ts | 234 ++++++-- .../tui/src/http/core_server_process.test.ts | 102 ++++ packages/tui/src/http/core_server_process.ts | 531 ++++++++++++++++++ packages/tui/src/http/http_agent_session.ts | 34 +- packages/tui/src/http/shared_core_client.ts | 50 ++ packages/tui/src/mcp.test.ts | 14 +- packages/tui/src/mcp.ts | 220 +++----- packages/tui/src/review/backend.test.ts | 2 +- packages/tui/src/review/backend.ts | 2 +- packages/tui/src/setup/SetupWizard.tsx | 27 +- packages/tui/src/slash/registry.ts | 255 ++++++++- packages/tui/src/slash/types.ts | 53 +- packages/tui/src/state/chat_timeline.ts | 2 +- packages/tui/src/types.ts | 2 +- packages/tui/src/web/run_web_command.test.ts | 66 +-- packages/tui/src/web/run_web_command.ts | 128 +---- packages/types/package.json | 8 + packages/types/src/index.ts | 422 ++++++++++++++ packages/web-ui/package.json | 4 +- packages/web-ui/src/api/types.ts | 4 +- packages/web-ui/tsconfig.app.json | 3 +- packages/web-ui/tsconfig.json | 3 +- pnpm-lock.yaml | 12 +- tsconfig.json | 2 + tsup.config.ts | 3 +- 86 files changed, 2945 insertions(+), 1613 deletions(-) rename packages/core/src/runtime/{ => agent}/compact_prompt.test.ts (98%) rename packages/core/src/runtime/{ => agent}/compact_prompt.ts (100%) rename packages/core/src/runtime/{ => agent}/hooks.test.ts (99%) rename packages/core/src/runtime/{ => agent}/hooks.ts (100%) rename packages/core/src/runtime/{ => agent}/session_hooks.test.ts (99%) rename packages/core/src/runtime/{ => agent}/session_runtime.ts (99%) rename packages/core/src/runtime/{ => agent}/session_runtime_helpers.test.ts (99%) rename packages/core/src/runtime/{ => agent}/session_runtime_helpers.ts (100%) rename packages/core/src/runtime/{history.test.ts => history/index.test.ts} (100%) rename packages/core/src/runtime/{history.ts => history/index.ts} (100%) rename packages/core/src/runtime/{history_index.test.ts => history/indexer.test.ts} (99%) rename packages/core/src/runtime/{history_index.ts => history/indexer.ts} (98%) rename packages/core/src/runtime/{history_parser.test.ts => history/parser.test.ts} (97%) rename packages/core/src/runtime/{history_parser.ts => history/parser.ts} (99%) rename packages/core/src/runtime/{mcp_admin.test.ts => mcp/admin.test.ts} (99%) rename packages/core/src/runtime/{mcp_admin.ts => mcp/admin.ts} (99%) rename packages/core/src/runtime/{model_profile.test.ts => model/profile.test.ts} (98%) rename packages/core/src/runtime/{model_profile.ts => model/profile.ts} (100%) rename packages/core/src/runtime/{prompt.test.ts => prompt/index.test.ts} (99%) rename packages/core/src/runtime/{prompt.ts => prompt/index.ts} (100%) rename packages/core/src/runtime/{ => prompt}/memory.test.ts (100%) rename packages/core/src/runtime/{ => prompt}/prompt.md (100%) rename packages/core/src/runtime/{ => session}/defaults.test.ts (93%) rename packages/core/src/runtime/{ => session}/defaults.ts (100%) rename packages/core/src/runtime/{ => session}/defaults.with_default_deps.test.ts (98%) rename packages/core/src/runtime/{session.ts => session/index.ts} (78%) rename packages/core/src/runtime/{skills_admin.test.ts => skills/admin.test.ts} (99%) rename packages/core/src/runtime/{skills_admin.ts => skills/admin.ts} (98%) rename packages/core/src/runtime/{skills.test.ts => skills/index.test.ts} (100%) rename packages/core/src/runtime/{skills.ts => skills/index.ts} (100%) delete mode 100644 packages/core/src/runtime/slash/index.ts delete mode 100644 packages/core/src/runtime/slash/registry.test.ts delete mode 100644 packages/core/src/runtime/slash/registry.ts delete mode 100644 packages/core/src/runtime/slash/types.ts rename packages/core/src/runtime/{ => workspace}/file_suggestions.test.ts (100%) rename packages/core/src/runtime/{ => workspace}/file_suggestions.ts (100%) rename packages/core/src/runtime/{workspace.test.ts => workspace/index.test.ts} (98%) rename packages/core/src/runtime/{workspace.ts => workspace/index.ts} (100%) create mode 100644 packages/core/src/server/process_entry.ts create mode 100644 packages/tui/src/http/api_types.ts create mode 100644 packages/tui/src/http/core_server_process.test.ts create mode 100644 packages/tui/src/http/core_server_process.ts create mode 100644 packages/tui/src/http/shared_core_client.ts create mode 100644 packages/types/package.json create mode 100644 packages/types/src/index.ts diff --git a/docs/core.md b/docs/core.md index ae016dc..0c5bc41 100644 --- a/docs/core.md +++ b/docs/core.md @@ -96,7 +96,7 @@ await session.close() - Default output path: `~/.memo/sessions/-/-.jsonl`, with provider/model/tokenizer/token-usage metadata. - For concurrent calls, each tool observation is logged individually, and merged observation is also recorded. -## LLM Adapter (`runtime/defaults.ts`) +## LLM Adapter (`runtime/session/defaults.ts`) - `withDefaultDeps` provides OpenAI SDK based invocation (selected by provider/model/base_url/env_api_key). - **Automatically generates Tool Use API tool definitions**: `toolRouter.generateToolDefinitions()`. @@ -200,7 +200,7 @@ if (toolUseBlocks.length > 1) { } ``` -## System Prompt (`runtime/prompt.md`) +## System Prompt (`runtime/prompt/prompt.md`) Incorporates Claude Code best practices: diff --git a/docs/model-agnostic-design.md b/docs/model-agnostic-design.md index 82f6fd4..549a081 100644 --- a/docs/model-agnostic-design.md +++ b/docs/model-agnostic-design.md @@ -51,7 +51,7 @@ base_url = "http://localhost:11434/v1" ### 2. 统一 HTTP 客户端层 -位置:`packages/core/src/runtime/defaults.ts:147-174` +位置:`packages/core/src/runtime/session/defaults.ts:147-174` 使用 OpenAI SDK 作为统一接口: @@ -70,7 +70,7 @@ const client = new OpenAI({ ### 3. 消息格式转换层 -位置:`packages/core/src/runtime/defaults.ts:34-60` +位置:`packages/core/src/runtime/session/defaults.ts:34-60` 将内部 `ChatMessage` 格式转换为 OpenAI API 格式: @@ -113,7 +113,7 @@ function toOpenAIMessage(message: ChatMessage): OpenAI.ChatCompletionMessagePara ### 4. 响应格式归一化层 -位置:`packages/core/src/runtime/defaults.ts:176-236` +位置:`packages/core/src/runtime/session/defaults.ts:176-236` 将模型响应转换为内部统一的 `LLMResponse` 格式: @@ -206,6 +206,6 @@ base_url = "https://your-api-endpoint.com/v1" ## 相关文件 - `packages/core/src/config/config.ts` - Provider 配置管理 -- `packages/core/src/runtime/defaults.ts` - HTTP 客户端和消息转换 +- `packages/core/src/runtime/session/defaults.ts` - HTTP 客户端和消息转换 - `packages/core/src/types.ts` - 统一类型定义 - `packages/tui/src/slash/registry.ts` - CLI 命令处理 diff --git a/docs/npm-distribution-design.md b/docs/npm-distribution-design.md index fafe020..68037b0 100644 --- a/docs/npm-distribution-design.md +++ b/docs/npm-distribution-design.md @@ -99,7 +99,7 @@ export default defineConfig({ }, onSuccess() { // copy runtime resource file - copyFileSync('packages/core/src/runtime/prompt.md', 'dist/prompt.md') + copyFileSync('packages/core/src/runtime/prompt/prompt.md', 'dist/prompt.md') }, }) ``` diff --git a/package.json b/package.json index 4e1c8c1..453e66d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "files": [ "dist/index.js", + "dist/core-server.js", "dist/prompt.md", "dist/task-prompts/*.md", "dist/web/**/*", diff --git a/packages/core/package.json b/packages/core/package.json index 83cd87a..1e037ca 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,10 +15,11 @@ "version": "0.1.0", "private": true, "scripts": { - "build": "tsup --config tsup.config.ts && node -e \"const { copyFileSync } = require('node:fs'); copyFileSync('src/runtime/prompt.md', 'dist/prompt.md');\"", + "build": "tsup --config tsup.config.ts && node -e \"const { copyFileSync } = require('node:fs'); copyFileSync('src/runtime/prompt/prompt.md', 'dist/prompt.md');\"", "test": "vitest run" }, "dependencies": { + "@memo-code/types": "workspace:*", "ignore": "^7.0.5", "zod": "^4.3.6" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 180a2db..3a937d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,15 +2,14 @@ export * from './types' export * from './runtime/prompt' export * from './runtime/skills' +export * from './runtime/skills/admin' export * from './runtime/history' -export * from './runtime/history_parser' -export * from './runtime/history_index' +export * from './runtime/history/parser' +export * from './runtime/history/indexer' export * from './runtime/workspace' -export * from './runtime/file_suggestions' -export * from './runtime/slash' -export * from './runtime/mcp_admin' -export * from './runtime/skills_admin' -export * from './runtime/defaults' +export * from './runtime/workspace/file_suggestions' +export * from './runtime/mcp/admin' +export * from './runtime/session/defaults' export * from './config/config' export * from './utils/utils' export * from './utils/tokenizer' diff --git a/packages/core/src/runtime/compact_prompt.test.ts b/packages/core/src/runtime/agent/compact_prompt.test.ts similarity index 98% rename from packages/core/src/runtime/compact_prompt.test.ts rename to packages/core/src/runtime/agent/compact_prompt.test.ts index 59dacca..04298ee 100644 --- a/packages/core/src/runtime/compact_prompt.test.ts +++ b/packages/core/src/runtime/agent/compact_prompt.test.ts @@ -5,7 +5,7 @@ import { buildCompactionUserPrompt, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/runtime/compact_prompt' +} from '@memo/core/runtime/agent/compact_prompt' describe('compact_prompt', () => { test('buildCompactionUserPrompt formats assistant tool calls and tool messages', () => { diff --git a/packages/core/src/runtime/compact_prompt.ts b/packages/core/src/runtime/agent/compact_prompt.ts similarity index 100% rename from packages/core/src/runtime/compact_prompt.ts rename to packages/core/src/runtime/agent/compact_prompt.ts diff --git a/packages/core/src/runtime/hooks.test.ts b/packages/core/src/runtime/agent/hooks.test.ts similarity index 99% rename from packages/core/src/runtime/hooks.test.ts rename to packages/core/src/runtime/agent/hooks.test.ts index 3821f0e..91bdbb0 100644 --- a/packages/core/src/runtime/hooks.test.ts +++ b/packages/core/src/runtime/agent/hooks.test.ts @@ -8,7 +8,7 @@ import type { ChatMessage, AssistantToolCall, } from '@memo/core/types' -import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/runtime/hooks' +import { buildHookRunners, runHook, snapshotHistory } from '@memo/core/runtime/agent/hooks' describe('buildHookRunners', () => { test('creates empty hook map when no hooks provided', () => { diff --git a/packages/core/src/runtime/hooks.ts b/packages/core/src/runtime/agent/hooks.ts similarity index 100% rename from packages/core/src/runtime/hooks.ts rename to packages/core/src/runtime/agent/hooks.ts diff --git a/packages/core/src/runtime/session_hooks.test.ts b/packages/core/src/runtime/agent/session_hooks.test.ts similarity index 99% rename from packages/core/src/runtime/session_hooks.test.ts rename to packages/core/src/runtime/agent/session_hooks.test.ts index ee237ba..33a7492 100644 --- a/packages/core/src/runtime/session_hooks.test.ts +++ b/packages/core/src/runtime/agent/session_hooks.test.ts @@ -7,7 +7,7 @@ import type { Tool } from '@memo/tools/router' import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX, -} from '@memo/core/runtime/compact_prompt' +} from '@memo/core/runtime/agent/compact_prompt' const echoTool: Tool = { name: 'echo', diff --git a/packages/core/src/runtime/session_runtime.ts b/packages/core/src/runtime/agent/session_runtime.ts similarity index 99% rename from packages/core/src/runtime/session_runtime.ts rename to packages/core/src/runtime/agent/session_runtime.ts index e8fe6c6..b9d54d1 100644 --- a/packages/core/src/runtime/session_runtime.ts +++ b/packages/core/src/runtime/agent/session_runtime.ts @@ -7,7 +7,7 @@ import { CONTEXT_COMPACTION_SYSTEM_PROMPT, CONTEXT_SUMMARY_PREFIX, isContextSummaryMessage, -} from '@memo/core/runtime/compact_prompt' +} from '@memo/core/runtime/agent/compact_prompt' import type { ChatMessage, AgentSession, @@ -32,7 +32,7 @@ import { runHook, snapshotHistory, type HookRunnerMap, -} from '@memo/core/runtime/hooks' +} from '@memo/core/runtime/agent/hooks' import { createToolOrchestrator, type ToolApprovalHooks, @@ -58,7 +58,7 @@ import { resolveToolPermission, stableStringify, toToolHistoryMessage, -} from '@memo/core/runtime/session_runtime_helpers' +} from '@memo/core/runtime/agent/session_runtime_helpers' import type { ApprovalRequest, ApprovalDecision } from '@memo/tools/approval' const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 diff --git a/packages/core/src/runtime/session_runtime_helpers.test.ts b/packages/core/src/runtime/agent/session_runtime_helpers.test.ts similarity index 99% rename from packages/core/src/runtime/session_runtime_helpers.test.ts rename to packages/core/src/runtime/agent/session_runtime_helpers.test.ts index 082f67b..8f9f269 100644 --- a/packages/core/src/runtime/session_runtime_helpers.test.ts +++ b/packages/core/src/runtime/agent/session_runtime_helpers.test.ts @@ -12,7 +12,7 @@ import { stableStringify, toToolHistoryMessage, truncateSessionTitle, -} from '@memo/core/runtime/session_runtime_helpers' +} from '@memo/core/runtime/agent/session_runtime_helpers' describe('accumulateUsage', () => { test('uses explicit total when provided', () => { diff --git a/packages/core/src/runtime/session_runtime_helpers.ts b/packages/core/src/runtime/agent/session_runtime_helpers.ts similarity index 100% rename from packages/core/src/runtime/session_runtime_helpers.ts rename to packages/core/src/runtime/agent/session_runtime_helpers.ts diff --git a/packages/core/src/runtime/history.test.ts b/packages/core/src/runtime/history/index.test.ts similarity index 100% rename from packages/core/src/runtime/history.test.ts rename to packages/core/src/runtime/history/index.test.ts diff --git a/packages/core/src/runtime/history.ts b/packages/core/src/runtime/history/index.ts similarity index 100% rename from packages/core/src/runtime/history.ts rename to packages/core/src/runtime/history/index.ts diff --git a/packages/core/src/runtime/history_index.test.ts b/packages/core/src/runtime/history/indexer.test.ts similarity index 99% rename from packages/core/src/runtime/history_index.test.ts rename to packages/core/src/runtime/history/indexer.test.ts index 40dd448..c1b7273 100644 --- a/packages/core/src/runtime/history_index.test.ts +++ b/packages/core/src/runtime/history/indexer.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, test } from 'vitest' -import { aggregateToolUsage, HistoryIndex } from './history_index' +import { aggregateToolUsage, HistoryIndex } from './indexer' function logFor(sessionId: string, cwd: string, startedAt: string, extras: string[] = []): string { const day = startedAt.slice(0, 10) diff --git a/packages/core/src/runtime/history_index.ts b/packages/core/src/runtime/history/indexer.ts similarity index 98% rename from packages/core/src/runtime/history_index.ts rename to packages/core/src/runtime/history/indexer.ts index 8d3c12f..bce740b 100644 --- a/packages/core/src/runtime/history_index.ts +++ b/packages/core/src/runtime/history/indexer.ts @@ -7,9 +7,9 @@ import type { SessionListItem, SessionListResponse, ToolUsageSummary, -} from '../web/types.js' -import { parseHistoryLogToSessionDetail } from './history_parser.js' -import { cwdBelongsToWorkspace } from './workspace.js' +} from '../../web/types.js' +import { parseHistoryLogToSessionDetail } from './parser.js' +import { cwdBelongsToWorkspace } from '../workspace/index.js' type SessionFileMeta = { filePath: string diff --git a/packages/core/src/runtime/history_parser.test.ts b/packages/core/src/runtime/history/parser.test.ts similarity index 97% rename from packages/core/src/runtime/history_parser.test.ts rename to packages/core/src/runtime/history/parser.test.ts index fb19f8f..f4585e6 100644 --- a/packages/core/src/runtime/history_parser.test.ts +++ b/packages/core/src/runtime/history/parser.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert' import { describe, test } from 'vitest' -import { parseHistoryLogToSessionDetail } from './history_parser' +import { parseHistoryLogToSessionDetail } from './parser' function buildSampleLog(): string { return [ diff --git a/packages/core/src/runtime/history_parser.ts b/packages/core/src/runtime/history/parser.ts similarity index 99% rename from packages/core/src/runtime/history_parser.ts rename to packages/core/src/runtime/history/parser.ts index 0ba829f..275b66b 100644 --- a/packages/core/src/runtime/history_parser.ts +++ b/packages/core/src/runtime/history/parser.ts @@ -8,8 +8,8 @@ import type { SessionTurnStep, TokenUsageSummary, ToolUsageSummary, -} from '../web/types.js' -import { workspaceIdFromCwd } from './workspace.js' +} from '../../web/types.js' +import { workspaceIdFromCwd } from '../workspace/index.js' type MutableTurnDetail = SessionTurnDetail & { byStep: Map diff --git a/packages/core/src/runtime/mcp_admin.test.ts b/packages/core/src/runtime/mcp/admin.test.ts similarity index 99% rename from packages/core/src/runtime/mcp_admin.test.ts rename to packages/core/src/runtime/mcp/admin.test.ts index 44d7806..e601a0f 100644 --- a/packages/core/src/runtime/mcp_admin.test.ts +++ b/packages/core/src/runtime/mcp/admin.test.ts @@ -31,7 +31,7 @@ import { removeMcpServer, setActiveMcpServers, updateMcpServer, -} from './mcp_admin' +} from './admin' type LoadedState = { configPath: string diff --git a/packages/core/src/runtime/mcp_admin.ts b/packages/core/src/runtime/mcp/admin.ts similarity index 99% rename from packages/core/src/runtime/mcp_admin.ts rename to packages/core/src/runtime/mcp/admin.ts index 7dd90b6..3814bb7 100644 --- a/packages/core/src/runtime/mcp_admin.ts +++ b/packages/core/src/runtime/mcp/admin.ts @@ -10,7 +10,7 @@ import { logoutMcpServerOAuth, type McpAuthStatus, } from '@memo/tools/router/mcp/oauth' -import type { McpServerRecord } from '../web/types.js' +import type { McpServerRecord } from '../../web/types.js' export class McpAdminError extends Error { constructor( diff --git a/packages/core/src/runtime/model_profile.test.ts b/packages/core/src/runtime/model/profile.test.ts similarity index 98% rename from packages/core/src/runtime/model_profile.test.ts rename to packages/core/src/runtime/model/profile.test.ts index 2d0f17f..c810631 100644 --- a/packages/core/src/runtime/model_profile.test.ts +++ b/packages/core/src/runtime/model/profile.test.ts @@ -3,7 +3,7 @@ import { buildChatCompletionRequest, resolveModelProfile, type ModelProfile, -} from '@memo/core/runtime/model_profile' +} from '@memo/core/runtime/model/profile' import type { ToolDefinition } from '@memo/core/types' function sampleProfile(overrides: Partial = {}): ModelProfile { diff --git a/packages/core/src/runtime/model_profile.ts b/packages/core/src/runtime/model/profile.ts similarity index 100% rename from packages/core/src/runtime/model_profile.ts rename to packages/core/src/runtime/model/profile.ts diff --git a/packages/core/src/runtime/prompt.test.ts b/packages/core/src/runtime/prompt/index.test.ts similarity index 99% rename from packages/core/src/runtime/prompt.test.ts rename to packages/core/src/runtime/prompt/index.test.ts index 5214391..f7bbdf0 100644 --- a/packages/core/src/runtime/prompt.test.ts +++ b/packages/core/src/runtime/prompt/index.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'vitest' -import { loadSystemPrompt } from './prompt' +import { loadSystemPrompt } from './index' const createdDirs: string[] = [] diff --git a/packages/core/src/runtime/prompt.ts b/packages/core/src/runtime/prompt/index.ts similarity index 100% rename from packages/core/src/runtime/prompt.ts rename to packages/core/src/runtime/prompt/index.ts diff --git a/packages/core/src/runtime/memory.test.ts b/packages/core/src/runtime/prompt/memory.test.ts similarity index 100% rename from packages/core/src/runtime/memory.test.ts rename to packages/core/src/runtime/prompt/memory.test.ts diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt/prompt.md similarity index 100% rename from packages/core/src/runtime/prompt.md rename to packages/core/src/runtime/prompt/prompt.md diff --git a/packages/core/src/runtime/defaults.test.ts b/packages/core/src/runtime/session/defaults.test.ts similarity index 93% rename from packages/core/src/runtime/defaults.test.ts rename to packages/core/src/runtime/session/defaults.test.ts index 26bdbb8..16a4fd7 100644 --- a/packages/core/src/runtime/defaults.test.ts +++ b/packages/core/src/runtime/session/defaults.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'vitest' -import { parseToolArguments, filterMcpServersBySelection } from '@memo/core/runtime/defaults' +import { + parseToolArguments, + filterMcpServersBySelection, +} from '@memo/core/runtime/session/defaults' describe('parseToolArguments', () => { test('parses valid JSON string', () => { diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/session/defaults.ts similarity index 100% rename from packages/core/src/runtime/defaults.ts rename to packages/core/src/runtime/session/defaults.ts diff --git a/packages/core/src/runtime/defaults.with_default_deps.test.ts b/packages/core/src/runtime/session/defaults.with_default_deps.test.ts similarity index 98% rename from packages/core/src/runtime/defaults.with_default_deps.test.ts rename to packages/core/src/runtime/session/defaults.with_default_deps.test.ts index 21e816f..4abad04 100644 --- a/packages/core/src/runtime/defaults.with_default_deps.test.ts +++ b/packages/core/src/runtime/session/defaults.with_default_deps.test.ts @@ -203,7 +203,7 @@ describe('withDefaultDeps (default path)', () => { }) test('builds default deps with injected tool descriptions and default sinks', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const resolved = await withDefaultDeps( {}, @@ -222,7 +222,7 @@ describe('withDefaultDeps (default path)', () => { }) test('respects provided deps overrides (callLLM/historySinks/tokenCounter/loadPrompt/dispose)', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const callLLM = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'override' }], stop_reason: 'end_turn' as const, @@ -260,7 +260,7 @@ describe('withDefaultDeps (default path)', () => { }) test('throws when provider api key is missing', async () => { - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3') await expect( @@ -270,7 +270,7 @@ describe('withDefaultDeps (default path)', () => { test('uses provider env key and provider base_url', async () => { process.env.MOCK_API_KEY = 'mock-provider-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3b') await resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage]) @@ -283,7 +283,7 @@ describe('withDefaultDeps (default path)', () => { test('falls back to OPENAI_API_KEY when provider key is missing', async () => { process.env.OPENAI_API_KEY = 'openai-fallback-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const resolved = await withDefaultDeps({}, {} as AgentSessionOptions, 'session-3c') await resolved.callLLM([{ role: 'user', content: 'hello' } as ChatMessage]) @@ -295,7 +295,7 @@ describe('withDefaultDeps (default path)', () => { test('maps AI SDK tool calls into tool_use blocks', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') const callOptionsTools: ToolDefinition[] = [ { name: 'override', @@ -385,7 +385,7 @@ describe('withDefaultDeps (default path)', () => { test('returns plain text end_turn response with usage', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') state.generateTextResponse = { text: 'plain assistant answer', @@ -409,7 +409,7 @@ describe('withDefaultDeps (default path)', () => { test('throws when AI SDK returns empty content', async () => { process.env.MOCK_API_KEY = 'test-key' - const { withDefaultDeps } = await import('@memo/core/runtime/defaults') + const { withDefaultDeps } = await import('@memo/core/runtime/session/defaults') state.generateTextResponse = { text: '', diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/runtime/session/index.ts similarity index 78% rename from packages/core/src/runtime/session.ts rename to packages/core/src/runtime/session/index.ts index 48d4579..d17a557 100644 --- a/packages/core/src/runtime/session.ts +++ b/packages/core/src/runtime/session/index.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' -import { withDefaultDeps } from '@memo/core/runtime/defaults' -import { DEFAULT_SESSION_MODE } from '@memo/core/runtime/session_runtime_helpers' -import { AgentSessionImpl } from '@memo/core/runtime/session_runtime' +import { withDefaultDeps } from '@memo/core/runtime/session/defaults' +import { DEFAULT_SESSION_MODE } from '@memo/core/runtime/agent/session_runtime_helpers' +import { AgentSessionImpl } from '@memo/core/runtime/agent/session_runtime' import type { AgentSession, AgentSessionDeps, AgentSessionOptions } from '@memo/core/types' /** diff --git a/packages/core/src/runtime/skills_admin.test.ts b/packages/core/src/runtime/skills/admin.test.ts similarity index 99% rename from packages/core/src/runtime/skills_admin.test.ts rename to packages/core/src/runtime/skills/admin.test.ts index 0022f00..de7bfd0 100644 --- a/packages/core/src/runtime/skills_admin.test.ts +++ b/packages/core/src/runtime/skills/admin.test.ts @@ -3,7 +3,7 @@ import { access, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'vitest' -import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../config/config' +import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../../config/config' import { SkillsAdminError, createSkill, @@ -12,7 +12,7 @@ import { removeSkill, setActiveSkills, updateSkill, -} from './skills_admin' +} from './admin' const tempRoots: string[] = [] diff --git a/packages/core/src/runtime/skills_admin.ts b/packages/core/src/runtime/skills/admin.ts similarity index 98% rename from packages/core/src/runtime/skills_admin.ts rename to packages/core/src/runtime/skills/admin.ts index 091e430..fc239d9 100644 --- a/packages/core/src/runtime/skills_admin.ts +++ b/packages/core/src/runtime/skills/admin.ts @@ -1,9 +1,9 @@ import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' -import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../config/config.js' -import { normalizeWorkspacePath } from './workspace.js' -import type { SkillRecord } from '../web/types.js' +import { loadMemoConfig, writeMemoConfig, type MemoConfig } from '../../config/config.js' +import { normalizeWorkspacePath } from '../workspace/index.js' +import type { SkillRecord } from '../../web/types.js' type SkillScope = 'project' | 'global' diff --git a/packages/core/src/runtime/skills.test.ts b/packages/core/src/runtime/skills/index.test.ts similarity index 100% rename from packages/core/src/runtime/skills.test.ts rename to packages/core/src/runtime/skills/index.test.ts diff --git a/packages/core/src/runtime/skills.ts b/packages/core/src/runtime/skills/index.ts similarity index 100% rename from packages/core/src/runtime/skills.ts rename to packages/core/src/runtime/skills/index.ts diff --git a/packages/core/src/runtime/slash/index.ts b/packages/core/src/runtime/slash/index.ts deleted file mode 100644 index 1ac2ae5..0000000 --- a/packages/core/src/runtime/slash/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types.js' -export * from './registry.js' diff --git a/packages/core/src/runtime/slash/registry.test.ts b/packages/core/src/runtime/slash/registry.test.ts deleted file mode 100644 index 9fbe0f9..0000000 --- a/packages/core/src/runtime/slash/registry.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import assert from 'node:assert' -import { describe, test } from 'vitest' -import { resolveSlashCommand } from './registry' -import { TOOL_PERMISSION_MODES, type SlashContext } from './types' - -function makeContext(overrides: Partial = {}): SlashContext { - return { - configPath: '/tmp/.memo/config.toml', - providerName: 'openai', - model: 'gpt-4.1-mini', - mcpServers: {}, - providers: [ - { - name: 'openai', - env_api_key: 'OPENAI_API_KEY', - model: 'gpt-4.1-mini', - base_url: 'https://api.openai.com/v1', - }, - { - name: 'deepseek', - env_api_key: 'DEEPSEEK_API_KEY', - model: 'deepseek-chat', - base_url: 'https://api.deepseek.com', - }, - ], - toolPermissionMode: TOOL_PERMISSION_MODES.ONCE, - ...overrides, - } -} - -describe('slash registry', () => { - test('returns help/exit/new/compact/init command kinds', () => { - const ctx = makeContext() - const help = resolveSlashCommand('/help', ctx) - assert.strictEqual(help.kind, 'message') - if (help.kind === 'message') { - assert.ok(help.content.includes('Available commands:')) - assert.ok(help.content.includes('/tools')) - } - - assert.deepStrictEqual(resolveSlashCommand('/exit', ctx), { kind: 'exit' }) - assert.deepStrictEqual(resolveSlashCommand('/new', ctx), { kind: 'new' }) - assert.deepStrictEqual(resolveSlashCommand('/compact', ctx), { kind: 'compact' }) - assert.deepStrictEqual(resolveSlashCommand('/init', ctx), { kind: 'init_agents_md' }) - }) - - test('parses review command from number/hash/url and validates usage', () => { - const ctx = makeContext() - assert.deepStrictEqual(resolveSlashCommand('/review 123', ctx), { - kind: 'review_pr', - prNumber: 123, - }) - assert.deepStrictEqual(resolveSlashCommand('/review #88', ctx), { - kind: 'review_pr', - prNumber: 88, - }) - assert.deepStrictEqual(resolveSlashCommand('/review https://github.com/a/b/pull/77', ctx), { - kind: 'review_pr', - prNumber: 77, - }) - - const invalid = resolveSlashCommand('/review abc', ctx) - assert.strictEqual(invalid.kind, 'message') - if (invalid.kind === 'message') { - assert.ok(invalid.content.includes('Usage: /review')) - } - }) - - test('handles models command for empty, switch, and not-found paths', () => { - const noProviders = resolveSlashCommand('/models', makeContext({ providers: [] })) - assert.strictEqual(noProviders.kind, 'message') - if (noProviders.kind === 'message') { - assert.ok(noProviders.content.includes('No providers configured')) - } - - const switchByProvider = resolveSlashCommand('/models deepseek', makeContext()) - assert.strictEqual(switchByProvider.kind, 'switch_model') - if (switchByProvider.kind === 'switch_model') { - assert.strictEqual(switchByProvider.provider.name, 'deepseek') - } - - const switchByModel = resolveSlashCommand('/models gpt-4.1-mini', makeContext()) - assert.strictEqual(switchByModel.kind, 'switch_model') - - const notFound = resolveSlashCommand('/models unknown-provider', makeContext()) - assert.strictEqual(notFound.kind, 'message') - if (notFound.kind === 'message') { - assert.ok(notFound.content.includes('Not found: unknown-provider')) - assert.ok(notFound.content.includes('(current)')) - } - }) - - test('handles tools mode display, alias parsing, unsupported and already-selected', () => { - const ctx = makeContext({ toolPermissionMode: TOOL_PERMISSION_MODES.ONCE }) - - const status = resolveSlashCommand('/tools', ctx) - assert.strictEqual(status.kind, 'message') - if (status.kind === 'message') { - assert.ok(status.content.includes('Current: once')) - assert.ok(status.content.includes('Modes: none, once, full')) - } - - const aliasFull = resolveSlashCommand('/tools dangerous', ctx) - assert.deepStrictEqual(aliasFull, { - kind: 'set_tool_permission', - mode: TOOL_PERMISSION_MODES.FULL, - }) - - const unsupported = resolveSlashCommand('/tools invalid', ctx) - assert.strictEqual(unsupported.kind, 'message') - if (unsupported.kind === 'message') { - assert.ok(unsupported.content.includes('Unsupported mode')) - } - - const already = resolveSlashCommand( - '/tools ask', - makeContext({ toolPermissionMode: 'once' }), - ) - assert.strictEqual(already.kind, 'message') - if (already.kind === 'message') { - assert.ok(already.content.includes('Already using once')) - } - }) - - test('lists MCP servers for stdio and streamable_http and handles empty case', () => { - const empty = resolveSlashCommand('/mcp', makeContext({ mcpServers: {} })) - assert.strictEqual(empty.kind, 'message') - if (empty.kind === 'message') { - assert.ok(empty.content.includes('No MCP servers configured')) - } - - const withServers = resolveSlashCommand( - '/mcp', - makeContext({ - mcpServers: { - remote: { - type: 'streamable_http', - url: 'https://example.com/mcp', - bearer_token_env_var: 'MCP_TOKEN', - }, - local: { - type: 'stdio', - command: 'node', - args: ['server.js', '--debug'], - }, - }, - }), - ) - - assert.strictEqual(withServers.kind, 'message') - if (withServers.kind === 'message') { - assert.ok(withServers.content.includes('Total: 2')) - assert.ok(withServers.content.includes('url: https://example.com/mcp')) - assert.ok(withServers.content.includes('bearer: MCP_TOKEN')) - assert.ok(withServers.content.includes('command: node')) - assert.ok(withServers.content.includes('args: server.js --debug')) - } - }) - - test('resume and unknown command branches', () => { - const ctx = makeContext() - const resume = resolveSlashCommand('/resume', ctx) - assert.strictEqual(resume.kind, 'message') - if (resume.kind === 'message') { - assert.ok(resume.content.includes('Type "resume"')) - } - - const unknown = resolveSlashCommand('/unknown', ctx) - assert.strictEqual(unknown.kind, 'message') - if (unknown.kind === 'message') { - assert.ok(unknown.content.includes('Unknown command: /unknown')) - assert.ok(unknown.content.includes('/help')) - } - }) -}) diff --git a/packages/core/src/runtime/slash/registry.ts b/packages/core/src/runtime/slash/registry.ts deleted file mode 100644 index 940efe7..0000000 --- a/packages/core/src/runtime/slash/registry.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { SlashCommandResult, SlashContext, SlashSpec } from './types' -import { formatSlashCommand, SLASH_COMMANDS, TOOL_PERMISSION_MODES } from './types' -import type { ToolPermissionMode } from '../../types.js' - -export const SLASH_SPECS: SlashSpec[] = [ - { name: SLASH_COMMANDS.HELP, description: 'Show command and shortcut help' }, - { name: SLASH_COMMANDS.EXIT, description: 'Exit current session' }, - { name: SLASH_COMMANDS.NEW, description: 'Start a fresh session' }, - { name: SLASH_COMMANDS.RESUME, description: 'List and load session history' }, - { name: SLASH_COMMANDS.REVIEW, description: 'Review a GitHub pull request and post comments' }, - { name: SLASH_COMMANDS.MODELS, description: 'List or switch configured models' }, - { - name: SLASH_COMMANDS.TOOLS, - description: 'Set tool permission mode (none/once/full)', - }, - { name: SLASH_COMMANDS.COMPACT, description: 'Compact conversation context now' }, - { name: SLASH_COMMANDS.MCP, description: 'Show configured MCP servers' }, - { name: SLASH_COMMANDS.INIT, description: 'Generate AGENTS.md with agent instructions' }, -] - -const TOOL_PERMISSION_MODE_ALIASES: Record = { - none: TOOL_PERMISSION_MODES.NONE, - off: TOOL_PERMISSION_MODES.NONE, - disabled: TOOL_PERMISSION_MODES.NONE, - 'no-tools': TOOL_PERMISSION_MODES.NONE, - once: TOOL_PERMISSION_MODES.ONCE, - ask: TOOL_PERMISSION_MODES.ONCE, - single: TOOL_PERMISSION_MODES.ONCE, - strict: TOOL_PERMISSION_MODES.ONCE, - full: TOOL_PERMISSION_MODES.FULL, - all: TOOL_PERMISSION_MODES.FULL, - dangerous: TOOL_PERMISSION_MODES.FULL, - 'full-access': TOOL_PERMISSION_MODES.FULL, -} - -function parseToolPermissionMode(input: string | undefined): ToolPermissionMode | null { - if (!input) return null - const normalized = input.trim().toLowerCase() - if (!normalized) return null - return TOOL_PERMISSION_MODE_ALIASES[normalized] ?? null -} - -function toolPermissionLabel(mode: ToolPermissionMode): string { - if (mode === TOOL_PERMISSION_MODES.NONE) return 'none (no tools)' - if (mode === TOOL_PERMISSION_MODES.ONCE) return 'once (approval required)' - return 'full (no approval)' -} - -export function buildHelpText(): string { - const maxName = SLASH_SPECS.reduce((max, item) => Math.max(max, item.name.length), 0) - const commandLines = SLASH_SPECS.map( - (item) => ` ${formatSlashCommand(item.name).padEnd(maxName + 3)} ${item.description}`, - ) - - return [ - 'Available commands:', - ...commandLines, - ' exit Exit session (without slash)', - '', - 'Shortcuts:', - ' Enter Send message', - ' Shift+Enter New line', - ' Up/Down Browse local input history', - ' Tab Accept active suggestion', - ' Ctrl+L Clear screen and start new session', - ' Esc Esc Interrupt running turn / clear input', - ].join('\n') -} - -function parseReviewPrNumber(input: string | undefined): number | null { - if (!input) return null - const normalized = input.trim() - if (!normalized) return null - - const directMatch = normalized.match(/^#?(\d+)$/) - if (directMatch) { - const parsed = Number(directMatch[1]) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null - } - - const urlMatch = normalized.match(/\/pull\/(\d+)(?:[/?#].*)?$/i) - if (urlMatch) { - const parsed = Number(urlMatch[1]) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null - } - - return null -} - -export function resolveSlashCommand(raw: string, context: SlashContext): SlashCommandResult { - const [commandRaw, ...rest] = raw.trim().slice(1).split(/\s+/) - const command = (commandRaw ?? '').toLowerCase() - - switch (command) { - case SLASH_COMMANDS.HELP: - return { kind: 'message', title: 'Help', content: buildHelpText() } - - case SLASH_COMMANDS.EXIT: - return { kind: 'exit' } - - case SLASH_COMMANDS.NEW: - return { kind: 'new' } - - case SLASH_COMMANDS.RESUME: - return { - kind: 'message', - title: 'Resume', - content: 'Type "resume" followed by keywords to load local session history.', - } - - case SLASH_COMMANDS.REVIEW: { - const arg = rest.join(' ').trim() - const prNumber = parseReviewPrNumber(arg) - if (!prNumber) { - return { - kind: 'message', - title: 'Review', - content: `Usage: ${formatSlashCommand(SLASH_COMMANDS.REVIEW)} \nExamples: /review 999, /review #999`, - } - } - return { - kind: 'review_pr', - prNumber, - } - } - - case SLASH_COMMANDS.MODELS: { - if (!context.providers.length) { - return { - kind: 'message', - title: 'Models', - content: `No providers configured. Check ${context.configPath}`, - } - } - - const query = rest.join(' ').trim() - const found = - context.providers.find((provider) => provider.name === query) ?? - context.providers.find((provider) => provider.model === query) - - if (found) { - return { kind: 'switch_model', provider: found } - } - - const lines = context.providers.map((provider) => { - const marker = - provider.name === context.providerName && provider.model === context.model - ? ' (current)' - : '' - const base = provider.base_url ? ` @ ${provider.base_url}` : '' - return `- ${provider.name}: ${provider.model}${base}${marker}` - }) - - const prefix = query ? `Not found: ${query}\n\n` : '' - return { - kind: 'message', - title: 'Models', - content: `${prefix}${lines.join('\n')}`, - } - } - - case SLASH_COMMANDS.TOOLS: { - const rawMode = rest.join(' ').trim() - const parsedMode = parseToolPermissionMode(rawMode) - const options = ['none', 'once', 'full'].join(', ') - - if (!rawMode) { - return { - kind: 'message', - title: 'Tools', - content: `Current: ${toolPermissionLabel(context.toolPermissionMode)}\nUsage: ${formatSlashCommand(SLASH_COMMANDS.TOOLS)} \nModes: ${options}`, - } - } - - if (!parsedMode) { - return { - kind: 'message', - title: 'Tools', - content: `Unsupported mode: ${rawMode}\nChoose one of: ${options}`, - } - } - - if (parsedMode === context.toolPermissionMode) { - return { - kind: 'message', - title: 'Tools', - content: `Already using ${toolPermissionLabel(parsedMode)}.`, - } - } - - return { - kind: 'set_tool_permission', - mode: parsedMode, - } - } - - case SLASH_COMMANDS.COMPACT: - return { kind: 'compact' } - - case SLASH_COMMANDS.MCP: { - const names = Object.keys(context.mcpServers) - if (!names.length) { - return { - kind: 'message', - title: 'MCP Servers', - content: 'No MCP servers configured in current config.', - } - } - - const lines: string[] = [] - lines.push(`Total: ${names.length}`) - lines.push('') - - for (const [name, server] of Object.entries(context.mcpServers)) { - lines.push(`- ${name}`) - if ('url' in server) { - lines.push(` type: ${server.type ?? 'streamable_http'}`) - lines.push(` url: ${server.url}`) - if (server.bearer_token_env_var) { - lines.push(` bearer: ${server.bearer_token_env_var}`) - } - } else { - lines.push(` type: ${server.type ?? 'stdio'}`) - lines.push(` command: ${server.command}`) - if (server.args?.length) { - lines.push(` args: ${server.args.join(' ')}`) - } - } - lines.push('') - } - - return { - kind: 'message', - title: 'MCP Servers', - content: lines.join('\n'), - } - } - - case SLASH_COMMANDS.INIT: - return { kind: 'init_agents_md' } - - default: - return { - kind: 'message', - title: 'Unknown', - content: `Unknown command: ${raw}\nType ${formatSlashCommand(SLASH_COMMANDS.HELP)} for available commands.`, - } - } -} diff --git a/packages/core/src/runtime/slash/types.ts b/packages/core/src/runtime/slash/types.ts deleted file mode 100644 index 95f3baa..0000000 --- a/packages/core/src/runtime/slash/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ProviderConfig, MCPServerConfig } from '@memo/core/config/config' -import type { ToolPermissionMode } from '../../types.js' - -export const SLASH_COMMANDS = { - HELP: 'help', - EXIT: 'exit', - NEW: 'new', - RESUME: 'resume', - REVIEW: 'review', - MODELS: 'models', - TOOLS: 'tools', - COMPACT: 'compact', - MCP: 'mcp', - INIT: 'init', -} as const - -export type SlashCommandName = (typeof SLASH_COMMANDS)[keyof typeof SLASH_COMMANDS] - -export const TOOL_PERMISSION_MODES = { - NONE: 'none', - ONCE: 'once', - FULL: 'full', -} as const - -export function formatSlashCommand(command: SlashCommandName): string { - return `/${command}` -} - -export type SlashContext = { - configPath: string - providerName: string - model: string - mcpServers: Record - providers: ProviderConfig[] - toolPermissionMode: ToolPermissionMode -} - -export type SlashCommandResult = - | { kind: 'exit' } - | { kind: 'new' } - | { kind: 'message'; title: string; content: string } - | { kind: 'review_pr'; prNumber: number } - | { kind: 'switch_model'; provider: ProviderConfig } - | { kind: 'set_tool_permission'; mode: ToolPermissionMode } - | { kind: 'compact' } - | { kind: 'init_agents_md' } - -export type SlashSpec = { - name: SlashCommandName - description: string -} diff --git a/packages/core/src/runtime/file_suggestions.test.ts b/packages/core/src/runtime/workspace/file_suggestions.test.ts similarity index 100% rename from packages/core/src/runtime/file_suggestions.test.ts rename to packages/core/src/runtime/workspace/file_suggestions.test.ts diff --git a/packages/core/src/runtime/file_suggestions.ts b/packages/core/src/runtime/workspace/file_suggestions.ts similarity index 100% rename from packages/core/src/runtime/file_suggestions.ts rename to packages/core/src/runtime/workspace/file_suggestions.ts diff --git a/packages/core/src/runtime/workspace.test.ts b/packages/core/src/runtime/workspace/index.test.ts similarity index 98% rename from packages/core/src/runtime/workspace.test.ts rename to packages/core/src/runtime/workspace/index.test.ts index 994e07b..4b6fda6 100644 --- a/packages/core/src/runtime/workspace.test.ts +++ b/packages/core/src/runtime/workspace/index.test.ts @@ -6,7 +6,7 @@ import { normalizeWorkspaceName, normalizeWorkspacePath, workspaceIdFromCwd, -} from './workspace' +} from './index' describe('workspace runtime helpers', () => { test('normalizeWorkspacePath trims and normalizes separators', () => { diff --git a/packages/core/src/runtime/workspace.ts b/packages/core/src/runtime/workspace/index.ts similarity index 100% rename from packages/core/src/runtime/workspace.ts rename to packages/core/src/runtime/workspace/index.ts diff --git a/packages/core/src/server/handler/session_manager.ts b/packages/core/src/server/handler/session_manager.ts index 4c04e4d..dacc3a6 100644 --- a/packages/core/src/server/handler/session_manager.ts +++ b/packages/core/src/server/handler/session_manager.ts @@ -5,7 +5,7 @@ import { resolveContextWindowForProvider, selectProvider, } from '@memo/core/config/config' -import { HistoryIndex } from '@memo/core/runtime/history_index' +import { HistoryIndex } from '@memo/core/runtime/history/indexer' import { createAgentSession } from '@memo/core/runtime/session' import { defaultWorkspaceName, diff --git a/packages/core/src/server/http_server.test.ts b/packages/core/src/server/http_server.test.ts index 90be45c..a9774d2 100644 --- a/packages/core/src/server/http_server.test.ts +++ b/packages/core/src/server/http_server.test.ts @@ -199,6 +199,91 @@ describe('startCoreHttpServer', () => { } }) + test('supports config snapshot and patch APIs', async () => { + const memoHome = await mkdtemp(join(tmpdir(), 'memo-http-config-')) + const handle = await startCoreHttpServer({ + host: '127.0.0.1', + port: 0, + password: 'test-password', + memoHome, + }) + + try { + const login = await fetch(`${handle.url}/api/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ password: 'test-password' }), + }) + expect(login.status).toBe(200) + const loginBody = (await readJson(login)) as { + success: true + data: { accessToken: string } + } + expect(loginBody.success).toBe(true) + const token = loginBody.data.accessToken + + const snapshot = await fetch(`${handle.url}/api/config`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + expect(snapshot.status).toBe(200) + const snapshotBody = (await readJson(snapshot)) as { + success: true + data: { providers: Array<{ name: string }> } + } + expect(snapshotBody.success).toBe(true) + expect(Array.isArray(snapshotBody.data.providers)).toBe(true) + expect(snapshotBody.data.providers.length).toBeGreaterThan(0) + + const patch = await fetch(`${handle.url}/api/config`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + current_provider: 'openai', + providers: [ + { + name: 'openai', + env_api_key: 'OPENAI_API_KEY', + model: 'gpt-5', + }, + ], + mcp_servers: { + local: { + command: 'echo', + args: ['hello'], + }, + }, + active_mcp_servers: ['local'], + auto_compact_threshold_percent: 75, + }), + }) + expect(patch.status).toBe(200) + const patchBody = (await readJson(patch)) as { + success: true + data: { + currentProvider: string + providers: Array<{ name: string }> + activeMcpServers: string[] + autoCompactThresholdPercent: number + } + } + expect(patchBody.success).toBe(true) + expect(patchBody.data.currentProvider).toBe('openai') + expect(patchBody.data.providers[0]?.name).toBe('openai') + expect(patchBody.data.activeMcpServers).toEqual(['local']) + expect(patchBody.data.autoCompactThresholdPercent).toBe(75) + } finally { + await handle.close() + await rm(memoHome, { recursive: true, force: true }) + } + }) + test('allows browsing directories outside current working directory', async () => { const outsideDir = await mkdtemp(join(tmpdir(), 'memo-http-browser-')) const handle = await startCoreHttpServer({ diff --git a/packages/core/src/server/process_entry.ts b/packages/core/src/server/process_entry.ts new file mode 100644 index 0000000..a193abe --- /dev/null +++ b/packages/core/src/server/process_entry.ts @@ -0,0 +1,199 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { startCoreHttpServer, type CoreHttpServerHandle } from './http_server' + +type ProcessArgs = { + host: string + port: number + memoHome?: string + stateFile: string + staticDir?: string +} + +type ServerState = { + version: 1 + pid: number + host: string + port: number + baseUrl: string + password: string + memoHome?: string + stateFile: string + staticDir?: string + startedAt: string +} + +const DEFAULT_HOST = '127.0.0.1' +const DEFAULT_PORT = 5494 + +function parseArgs(argv: string[]): ProcessArgs { + let host = DEFAULT_HOST + let port = DEFAULT_PORT + let memoHome: string | undefined + let stateFile: string | undefined + let staticDir: string | undefined + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index] + if (!current) continue + + if (current === '--host') { + const next = argv[index + 1] + if (!next) throw new Error('Missing value for --host') + host = next + index += 1 + continue + } + + if (current === '--port') { + const next = argv[index + 1] + if (!next) throw new Error('Missing value for --port') + const parsed = Number.parseInt(next, 10) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error('Invalid --port value') + } + port = parsed + index += 1 + continue + } + + if (current === '--memo-home') { + const next = argv[index + 1] + if (!next) throw new Error('Missing value for --memo-home') + memoHome = next + index += 1 + continue + } + + if (current === '--state-file') { + const next = argv[index + 1] + if (!next) throw new Error('Missing value for --state-file') + stateFile = next + index += 1 + continue + } + + if (current === '--static-dir') { + const next = argv[index + 1] + if (!next) throw new Error('Missing value for --static-dir') + staticDir = next + index += 1 + continue + } + + throw new Error(`Unknown argument: ${current}`) + } + + const normalizedStateFile = stateFile?.trim() + if (!normalizedStateFile) { + const runtimeDir = memoHome ? join(memoHome, 'run') : join(process.cwd(), '.memo', 'run') + stateFile = join(runtimeDir, 'core-server.json') + } + + return { + host, + port, + memoHome, + stateFile: stateFile!, + staticDir, + } +} + +async function writeState(stateFile: string, state: ServerState): Promise { + const directory = dirname(stateFile) + await mkdir(directory, { recursive: true }) + + const tempFile = `${stateFile}.tmp-${process.pid}` + await writeFile(tempFile, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + encoding: 'utf8', + }) + await rm(stateFile, { force: true }) + await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + encoding: 'utf8', + }) + await rm(tempFile, { force: true }) +} + +async function removeStateFile(path: string): Promise { + await rm(path, { force: true }) +} + +async function run(): Promise { + const args = parseArgs(process.argv.slice(2)) + const password = process.env.MEMO_SERVER_PASSWORD?.trim() + + if (!password) { + throw new Error('MEMO_SERVER_PASSWORD is required for core server process') + } + + let handle: CoreHttpServerHandle | null = null + let shuttingDown = false + + const shutdown = async (reason: string) => { + if (shuttingDown) return + shuttingDown = true + + try { + if (handle) { + const current = handle + handle = null + await current.close() + } + } finally { + await removeStateFile(args.stateFile) + if (reason) { + process.stderr.write(`[core-server] stopped: ${reason}\n`) + } + } + } + + process.on('SIGINT', () => { + void shutdown('SIGINT').finally(() => process.exit(0)) + }) + process.on('SIGTERM', () => { + void shutdown('SIGTERM').finally(() => process.exit(0)) + }) + + process.on('uncaughtException', (error) => { + process.stderr.write(`[core-server] uncaughtException: ${String(error)}\n`) + void shutdown('uncaughtException').finally(() => process.exit(1)) + }) + process.on('unhandledRejection', (error) => { + process.stderr.write(`[core-server] unhandledRejection: ${String(error)}\n`) + void shutdown('unhandledRejection').finally(() => process.exit(1)) + }) + + handle = await startCoreHttpServer({ + host: args.host, + port: args.port, + password, + memoHome: args.memoHome, + staticDir: args.staticDir, + }) + + const parsed = new URL(handle.url) + const state: ServerState = { + version: 1, + pid: process.pid, + host: parsed.hostname, + port: Number.parseInt(parsed.port, 10), + baseUrl: handle.url, + password, + memoHome: args.memoHome, + stateFile: args.stateFile, + staticDir: args.staticDir, + startedAt: new Date().toISOString(), + } + await writeState(args.stateFile, state) + + await new Promise(() => { + // Keep process alive until a signal triggers shutdown. + }) +} + +void run().catch((error) => { + process.stderr.write(`[core-server] failed: ${(error as Error).message}\n`) + process.exit(1) +}) diff --git a/packages/core/src/server/router/api_routes.ts b/packages/core/src/server/router/api_routes.ts index 88b99e2..16df0d8 100644 --- a/packages/core/src/server/router/api_routes.ts +++ b/packages/core/src/server/router/api_routes.ts @@ -1,5 +1,16 @@ import { CoreAuth } from '@memo/core/server/handler/auth' import { CoreSessionManager } from '@memo/core/server/handler/session_manager' +import { + loadMemoConfig, + resolveContextWindowForProvider, + selectProvider, + writeMemoConfig, + type LoadedConfig, + type MCPServerConfig, + type MemoConfig, + type ModelProfileOverride, + type ProviderConfig, +} from '@memo/core/config/config' import { createMcpServer, getMcpServer, @@ -9,8 +20,8 @@ import { removeMcpServer, setActiveMcpServers, updateMcpServer, -} from '@memo/core/runtime/mcp_admin' -import { getFileSuggestions } from '@memo/core/runtime/file_suggestions' +} from '@memo/core/runtime/mcp/admin' +import { getFileSuggestions } from '@memo/core/runtime/workspace/file_suggestions' import { createSkill, getSkill, @@ -18,7 +29,7 @@ import { removeSkill, setActiveSkills, updateSkill, -} from '@memo/core/runtime/skills_admin' +} from '@memo/core/runtime/skills/admin' import { buildOpenApiSpec } from '@memo/core/server/router/openapi' import { HttpRouter, @@ -26,7 +37,7 @@ import { type RouteMethod, } from '@memo/core/server/router/http_router' import { SseHub } from '@memo/core/server/utils/sse' -import type { AuthLoginRequest } from '@memo/core/web/types' +import type { AuthLoginRequest, ConfigSnapshot, UpdateConfigRequest } from '@memo/core/web/types' import { ensureAuth, HttpApiError, @@ -53,6 +64,201 @@ export type RegisterCoreApiRoutesOptions = { getServerUrl: () => string } +function parseProvider(input: unknown, index: number): ProviderConfig { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', `providers[${index}] must be an object`) + } + const candidate = input as Record + const name = typeof candidate.name === 'string' ? candidate.name.trim() : '' + const envApiKey = typeof candidate.env_api_key === 'string' ? candidate.env_api_key.trim() : '' + const model = typeof candidate.model === 'string' ? candidate.model.trim() : '' + const baseUrl = + typeof candidate.base_url === 'string' && candidate.base_url.trim() + ? candidate.base_url.trim() + : undefined + + if (!name || !envApiKey || !model) { + throw new HttpApiError( + 400, + 'BAD_REQUEST', + `providers[${index}] requires name, env_api_key, and model`, + ) + } + + return { + name, + env_api_key: envApiKey, + model, + base_url: baseUrl, + } +} + +function parseProviders(input: unknown): ProviderConfig[] { + if (!Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'providers must be an array') + } + const providers = input.map((item, index) => parseProvider(item, index)) + if (providers.length === 0) { + throw new HttpApiError(400, 'BAD_REQUEST', 'providers cannot be empty') + } + return providers +} + +function parseModelProfiles(input: unknown): Record | undefined { + if (input === undefined || input === null) return undefined + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'model_profiles must be an object') + } + + const normalized: Record = {} + for (const [key, rawValue] of Object.entries(input as Record)) { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + continue + } + const profile = rawValue as Record + const next: ModelProfileOverride = {} + if (typeof profile.supports_parallel_tool_calls === 'boolean') { + next.supports_parallel_tool_calls = profile.supports_parallel_tool_calls + } + if (typeof profile.supports_reasoning_content === 'boolean') { + next.supports_reasoning_content = profile.supports_reasoning_content + } + if ( + typeof profile.context_window === 'number' && + Number.isFinite(profile.context_window) && + profile.context_window > 0 + ) { + next.context_window = Math.floor(profile.context_window) + } + if (Object.keys(next).length > 0) { + normalized[key] = next + } + } + + return Object.keys(normalized).length > 0 ? normalized : undefined +} + +function parseMcpServerConfig(name: string, input: unknown): MCPServerConfig { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', `mcp_servers.${name} must be an object`) + } + const config = input as Record + + if (typeof config.url === 'string' && config.url.trim()) { + const headers = + config.headers && typeof config.headers === 'object' && !Array.isArray(config.headers) + ? (config.headers as Record) + : undefined + const httpHeaders = + config.http_headers && + typeof config.http_headers === 'object' && + !Array.isArray(config.http_headers) + ? (config.http_headers as Record) + : undefined + return { + type: 'streamable_http', + url: config.url.trim(), + headers, + http_headers: httpHeaders, + bearer_token_env_var: + typeof config.bearer_token_env_var === 'string' && + config.bearer_token_env_var.trim() + ? config.bearer_token_env_var.trim() + : undefined, + } + } + + if (typeof config.command === 'string' && config.command.trim()) { + return { + type: 'stdio', + command: config.command.trim(), + args: Array.isArray(config.args) + ? config.args + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + : undefined, + env: + config.env && typeof config.env === 'object' && !Array.isArray(config.env) + ? (config.env as Record) + : undefined, + stderr: + config.stderr === 'inherit' || + config.stderr === 'pipe' || + config.stderr === 'ignore' + ? config.stderr + : undefined, + } + } + + throw new HttpApiError(400, 'BAD_REQUEST', `mcp_servers.${name} must contain url or command`) +} + +function parseMcpServers(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'mcp_servers must be an object') + } + + const normalized: Record = {} + for (const [name, value] of Object.entries(input as Record)) { + const trimmedName = name.trim() + if (!trimmedName) continue + normalized[trimmedName] = parseMcpServerConfig(trimmedName, value) + } + return normalized +} + +function parseActiveMcpServers(input: unknown): string[] { + if (!Array.isArray(input)) { + throw new HttpApiError(400, 'BAD_REQUEST', 'active_mcp_servers must be an array') + } + return Array.from( + new Set( + input + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + ), + ) +} + +function parseAutoCompactThresholdPercent(input: unknown): number { + if ( + typeof input !== 'number' || + !Number.isFinite(input) || + !Number.isInteger(input) || + input < 1 || + input > 100 + ) { + throw new HttpApiError( + 400, + 'BAD_REQUEST', + 'auto_compact_threshold_percent must be an integer between 1 and 100', + ) + } + return input +} + +function buildConfigSnapshot(loaded: LoadedConfig): ConfigSnapshot { + const selected = selectProvider(loaded.config, loaded.config.current_provider) + return { + configPath: loaded.configPath, + memoHome: loaded.home, + needsSetup: loaded.needsSetup, + currentProvider: selected.name, + selectedProvider: { + name: selected.name, + model: selected.model, + contextWindow: resolveContextWindowForProvider(loaded.config, selected), + }, + providers: loaded.config.providers, + modelProfiles: loaded.config.model_profiles, + mcpServers: loaded.config.mcp_servers ?? {}, + activeMcpServers: loaded.config.active_mcp_servers ?? [], + autoCompactThresholdPercent: loaded.config.auto_compact_threshold_percent ?? 80, + } +} + export function registerCoreApiRoutes(options: RegisterCoreApiRoutesOptions): void { const { auth, getServerUrl, router, sessionManager, sseHub, workspaceState } = options @@ -119,6 +325,80 @@ export function registerCoreApiRoutes(options: RegisterCoreApiRoutesOptions): vo } }) + router.register('GET', '/api/config', async (context) => { + try { + ensureAuth(auth, context.req) + const loaded = await loadMemoConfig() + writeSuccess(context.res, context.requestId, buildConfigSnapshot(loaded)) + } catch (error) { + const normalized = normalizeError(error) + writeError( + context.res, + context.requestId, + context.path, + normalized.statusCode, + normalized.code, + normalized.message, + normalized.details, + ) + } + }) + + registerJsonRoute('PATCH', '/api/config', async (_context, body) => { + const loaded = await loadMemoConfig() + const patch = body as UpdateConfigRequest + const nextConfig: MemoConfig = { + ...loaded.config, + } + + if (Object.prototype.hasOwnProperty.call(body, 'providers')) { + nextConfig.providers = parseProviders(patch.providers) + } + if (Object.prototype.hasOwnProperty.call(body, 'current_provider')) { + const currentProvider = + typeof patch.current_provider === 'string' ? patch.current_provider.trim() : '' + if (!currentProvider) { + throw new HttpApiError(400, 'BAD_REQUEST', 'current_provider must be a string') + } + nextConfig.current_provider = currentProvider + } + if (Object.prototype.hasOwnProperty.call(body, 'model_profiles')) { + nextConfig.model_profiles = parseModelProfiles(patch.model_profiles) + } + if (Object.prototype.hasOwnProperty.call(body, 'mcp_servers')) { + nextConfig.mcp_servers = parseMcpServers(patch.mcp_servers) + } + if (Object.prototype.hasOwnProperty.call(body, 'active_mcp_servers')) { + nextConfig.active_mcp_servers = parseActiveMcpServers(patch.active_mcp_servers) + } + if (Object.prototype.hasOwnProperty.call(body, 'auto_compact_threshold_percent')) { + nextConfig.auto_compact_threshold_percent = parseAutoCompactThresholdPercent( + patch.auto_compact_threshold_percent, + ) + } + + if (nextConfig.providers.length === 0) { + throw new HttpApiError(400, 'BAD_REQUEST', 'providers cannot be empty') + } + + const selectedProvider = selectProvider(nextConfig, nextConfig.current_provider) + nextConfig.current_provider = selectedProvider.name + + if (nextConfig.active_mcp_servers) { + const knownServers = new Set(Object.keys(nextConfig.mcp_servers ?? {})) + nextConfig.active_mcp_servers = nextConfig.active_mcp_servers.filter((name) => + knownServers.has(name), + ) + } + + await writeMemoConfig(loaded.configPath, nextConfig) + return buildConfigSnapshot({ + ...loaded, + config: nextConfig, + needsSetup: nextConfig.providers.length === 0, + }) + }) + registerJsonRoute('POST', '/api/chat/sessions', async (_context, body) => { return sessionManager.createSession({ sessionId: typeof body.sessionId === 'string' ? body.sessionId : undefined, @@ -226,7 +506,10 @@ export function registerCoreApiRoutes(options: RegisterCoreApiRoutesOptions): vo }) registerJsonRoute('POST', '/api/chat/files/suggest', async (_context, body) => { - const query = requireString(body, 'query') + if (typeof body.query !== 'string') { + throw new HttpApiError(400, 'BAD_REQUEST', 'query is required') + } + const query = body.query.trim() const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '' const workspaceCwd = typeof body.workspaceCwd === 'string' ? body.workspaceCwd.trim() : '' const sessionCwd = sessionId ? sessionManager.resolveSessionCwd(sessionId) : null @@ -244,11 +527,28 @@ export function registerCoreApiRoutes(options: RegisterCoreApiRoutesOptions): vo typeof body.limit === 'number' && Number.isFinite(body.limit) ? Math.max(1, Math.floor(body.limit)) : undefined + const maxDepth = + typeof body.maxDepth === 'number' && Number.isFinite(body.maxDepth) + ? Math.max(1, Math.floor(body.maxDepth)) + : undefined + const maxEntries = + typeof body.maxEntries === 'number' && Number.isFinite(body.maxEntries) + ? Math.max(100, Math.floor(body.maxEntries)) + : undefined + const respectGitIgnore = + typeof body.respectGitIgnore === 'boolean' ? body.respectGitIgnore : undefined + const ignoreGlobs = Array.isArray(body.ignoreGlobs) + ? body.ignoreGlobs.filter((item): item is string => typeof item === 'string') + : undefined const items = await getFileSuggestions({ cwd, query, limit, + maxDepth, + maxEntries, + respectGitIgnore, + ignoreGlobs, }) return { items } }) diff --git a/packages/core/src/server/router/openapi.ts b/packages/core/src/server/router/openapi.ts index c4cd9c9..d24000a 100644 --- a/packages/core/src/server/router/openapi.ts +++ b/packages/core/src/server/router/openapi.ts @@ -30,6 +30,14 @@ export function buildOpenApiSpec(options: { serverUrl: string }): Record = - | { - success: true - data: T - meta: ApiSuccessMeta - } - | { - success: false - error: ApiErrorInfo - meta: ApiErrorMeta - } - -export type AuthLoginRequest = { - password: string -} - -export type AuthLoginResponse = { - accessToken: string - expiresIn: number -} - -export type SseEventEnvelope = { - event: string - data: unknown - seq: number - ts: string -} - -export type TokenUsageSummary = { - prompt: number - completion: number - total: number -} - -export type ToolUsageSummary = { - total: number - success: number - failed: number - denied: number - cancelled: number -} - -export type SessionRuntimeStatus = 'idle' | 'running' | 'error' | 'cancelled' - -export type SessionDateInfo = { - day: string - startedAt: string - updatedAt: string -} - -export type SessionListItem = { - id: string - sessionId: string - filePath: string - title: string - project: string - workspaceId: string - cwd: string - date: SessionDateInfo - status: SessionRuntimeStatus - turnCount: number - tokenUsage: TokenUsageSummary - toolUsage: ToolUsageSummary -} - -export type SessionEventItem = { - index: number - ts: string - type: string - turn?: number - step?: number - role?: string - content?: string - meta?: Record -} - -export type SessionTurnStep = { - step: number - assistantText?: string - thinking?: string - action?: { - tool: string - input: unknown - } - parallelActions?: Array<{ - tool: string - input: unknown - }> - observation?: string - resultStatus?: string -} - -export type SessionTurnDetail = { - turn: number - input?: string - startedAt?: string - finalText?: string - status?: string - errorMessage?: string - tokenUsage?: TokenUsageSummary - steps: SessionTurnStep[] -} - -export type SessionDetail = SessionListItem & { - summary: string - turns: SessionTurnDetail[] - events: SessionEventItem[] -} - -export type SessionListResponse = { - items: SessionListItem[] - page: number - pageSize: number - total: number - totalPages: number -} - -export type SessionEventsResponse = { - items: SessionEventItem[] - nextCursor: string | null -} - -export type QueuedInputItem = { - id: string - input: string - createdAt: string -} - -export type LiveSessionState = { - id: string - title: string - workspaceId: string - projectName: string - providerName: string - model: string - cwd: string - startedAt: string - status: 'idle' | 'running' | 'closed' - pendingApproval?: { - fingerprint: string - toolName: string - reason: string - riskLevel: string - params: unknown - } - activeMcpServers: string[] - toolPermissionMode: 'none' | 'once' | 'full' - queuedInputs: QueuedInputItem[] - currentContextTokens?: number - contextWindow?: number - historyFilePath?: string - availableToolNames?: string[] -} - -export type WsServerEvent = - | { type: 'session.snapshot'; payload: LiveSessionState } - | { - type: 'turn.start' - payload: { turn: number; input: string; promptTokens?: number } - } - | { - type: 'assistant.chunk' - payload: { turn: number; step: number; chunk: string } - } - | { - type: 'context.usage' - payload: { - turn: number - step: number - phase: 'turn_start' | 'step_start' | 'post_compact' - promptTokens: number - contextWindow: number - thresholdTokens: number - usagePercent: number - } - } - | { - type: 'context.compact' - payload: { - turn: number - step: number - reason: 'auto' | 'manual' - status: 'success' | 'failed' | 'skipped' - beforeTokens: number - afterTokens: number - thresholdTokens: number - reductionPercent: number - summary?: string - errorMessage?: string - } - } - | { - type: 'tool.action' - payload: { - turn: number - step: number - action: { tool: string; input: unknown } - parallelActions?: Array<{ tool: string; input: unknown }> - thinking?: string - } - } - | { - type: 'tool.observation' - payload: { - turn: number - step: number - observation: string - resultStatus?: string - parallelResultStatuses?: string[] - } - } - | { - type: 'turn.final' - payload: { - turn: number - step?: number - finalText: string - status: string - errorMessage?: string - turnUsage?: TokenUsageSummary - tokenUsage?: TokenUsageSummary - } - } - | { - type: 'approval.request' - payload: { - fingerprint: string - toolName: string - reason: string - riskLevel: string - params: unknown - } - } - | { - type: 'session.status' - payload: { - status: 'idle' | 'running' | 'closed' - } - } - | { - type: 'system.message' - payload: { - title: string - content: string - tone?: 'info' | 'warning' | 'error' - } - } - | { - type: 'error' - payload: { - code: string - message: string - } - } - -export type SkillRecord = { - id: string - name: string - description: string - scope: 'project' | 'global' - path: string - active: boolean -} - -export type McpServerRecord = { - name: string - config: Record - authStatus: 'unsupported' | 'not_logged_in' | 'bearer_token' | 'oauth' - active: boolean -} - -export type WorkspaceRecord = { - id: string - name: string - cwd: string - createdAt: string - lastUsedAt: string -} - -export type WorkspaceDirEntry = { - name: string - path: string - kind: 'dir' - readable: boolean -} - -export type WorkspaceFsListResult = { - path: string - parentPath: string | null - items: WorkspaceDirEntry[] -} - -export type SessionRuntimeBadge = { - sessionId: string - status: 'idle' | 'running' | 'closed' - workspaceId: string - updatedAt: string -} +export type * from '@memo-code/types' diff --git a/packages/tui/package.json b/packages/tui/package.json index 9f68c8e..bc97a3a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@inkjs/ui": "^2.0.0", - "@memo-code/core": "workspace:*", + "@memo-code/types": "workspace:*", "@memo-code/tools": "workspace:*", "ignore": "^7.0.5", "ink": "^6.7.0", diff --git a/packages/tui/src/App.tsx b/packages/tui/src/App.tsx index f98b989..59ed210 100644 --- a/packages/tui/src/App.tsx +++ b/packages/tui/src/App.tsx @@ -1,21 +1,16 @@ import { randomUUID } from 'node:crypto' -import { readFile } from 'node:fs/promises' import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { Box, Text, useApp } from 'ink' -import { - loadMemoConfig, - resolveContextWindowForProvider, - selectProvider, - writeMemoConfig, - type AgentSession, - type AgentSessionDeps, - type AgentSessionOptions, - type ChatMessage, - type MCPServerConfig, - type ModelProfileOverride, - type ProviderConfig, -} from '@memo/core' import type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' +import type { + AgentSession, + AgentSessionDeps, + AgentSessionOptions, + ChatMessage, + MCPServerConfig, + ModelProfileOverride, + ProviderConfig, +} from './http/api_types' import { ChatWidget } from './chatwidget/ChatWidget' import { Composer } from './bottom_pane/Composer' import { Footer } from './bottom_pane/Footer' @@ -23,8 +18,9 @@ import { ApprovalOverlay } from './overlays/ApprovalOverlay' import { McpActivationOverlay } from './overlays/McpActivationOverlay' import { notifyApprovalRequested } from './notifications/approval_notification' import { SetupWizard } from './setup/SetupWizard' -import { parseHistoryLog } from './controllers/history_parser' +import { parseSessionDetail } from './controllers/history_parser' import { createHttpAgentSession } from './http/http_agent_session' +import { withSharedCoreServerClient } from './http/shared_core_client' import { chatTimelineReducer, createInitialTimelineState, @@ -51,7 +47,6 @@ export type AppProps = { configPath: string mcpServers: Record cwd: string - sessionsDir: string providers: ProviderConfig[] modelProfiles?: Record dangerous?: boolean @@ -93,6 +88,32 @@ function clearTerminalScreen() { } } +function resolveContextWindowForProviderLocal( + modelProfiles: Record | undefined, + provider: Pick, +): number { + const DEFAULT_CONTEXT_WINDOW = 120000 + if (!modelProfiles) return DEFAULT_CONTEXT_WINDOW + + const normalize = (value: string) => value.trim().toLowerCase() + const modelKey = normalize(provider.model) + const providerKey = `${normalize(provider.name)}:${modelKey}` + + const read = (key: string): number | undefined => { + const profile = modelProfiles[key] + if ( + typeof profile?.context_window === 'number' && + Number.isFinite(profile.context_window) && + profile.context_window > 0 + ) { + return Math.floor(profile.context_window) + } + return undefined + } + + return read(providerKey) ?? read(modelKey) ?? DEFAULT_CONTEXT_WINDOW +} + export function App({ sessionOptions, providerName, @@ -100,7 +121,6 @@ export function App({ configPath, mcpServers, cwd, - sessionsDir, providers, modelProfiles, dangerous = false, @@ -135,7 +155,7 @@ export function App({ const resolveContextLimit = useCallback( (providerConfig: Pick) => - resolveContextWindowForProvider({ model_profiles: modelProfilesState }, providerConfig), + resolveContextWindowForProviderLocal(modelProfilesState, providerConfig), [modelProfilesState], ) @@ -149,7 +169,6 @@ export function App({ const [busy, setBusy] = useState(false) const [inputHistory, setInputHistory] = useState([]) - const [sessionLogPath, setSessionLogPath] = useState(null) const [pendingHistoryMessages, setPendingHistoryMessages] = useState(null) const [contextLimit, setContextLimit] = useState( @@ -354,12 +373,10 @@ export function App({ sessionRef.current = created setSession(created) - setSessionLogPath(created.historyFilePath ?? null) } catch (err) { if (cancelled) return sessionRef.current = null setSession(null) - setSessionLogPath(null) setBusy(false) appendSystemMessage( 'Session', @@ -464,11 +481,9 @@ export function App({ const persistCurrentProvider = useCallback( async (name: string) => { try { - const loaded = await loadMemoConfig() - await writeMemoConfig(loaded.configPath, { - ...loaded.config, - current_provider: name, - }) + await withSharedCoreServerClient((client) => + client.patchConfig({ current_provider: name }), + ) } catch (err) { appendSystemMessage( 'Config', @@ -574,11 +589,7 @@ export function App({ const persistActiveMcpServers = useCallback( async (names: string[]) => { try { - const loaded = await loadMemoConfig() - await writeMemoConfig(loaded.configPath, { - ...loaded.config, - active_mcp_servers: names, - }) + await withSharedCoreServerClient((client) => client.setActiveMcpServers(names)) } catch (err) { appendSystemMessage( 'MCP', @@ -627,8 +638,10 @@ export function App({ return } try { - const raw = await readFile(entry.sessionFile, 'utf8') - const parsed = parseHistoryLog(raw) + const detail = await withSharedCoreServerClient((client) => + client.getSessionDetail(entry.id), + ) + const parsed = parseSessionDetail(detail) dispatch({ type: 'clear_current_timeline' }) dispatch({ type: 'replace_history', @@ -638,7 +651,6 @@ export function App({ setPendingHistoryMessages(parsed.messages) setBusy(false) setSession(null) - setSessionLogPath(null) setCurrentContextTokens(0) currentTurnRef.current = null setSessionOptionsState((prev) => ({ ...prev, sessionId: randomUUID() })) @@ -646,7 +658,7 @@ export function App({ } catch (err) { appendSystemMessage( 'History', - `Failed to load ${entry.sessionFile}: ${(err as Error).message}`, + `Failed to load session history: ${(err as Error).message}`, 'error', ) } @@ -801,23 +813,22 @@ export function App({ const handleSetupComplete = useCallback(async () => { try { - const loaded = await loadMemoConfig() - const provider = selectProvider(loaded.config) - const nextContextLimit = resolveContextWindowForProvider(loaded.config, provider) - setProvidersState(loaded.config.providers) - setModelProfilesState(loaded.config.model_profiles) + const snapshot = await withSharedCoreServerClient((client) => client.getConfig()) + const provider = snapshot.selectedProvider + setProvidersState(snapshot.providers) + setModelProfilesState(snapshot.modelProfiles) setCurrentProvider(provider.name) setCurrentModel(provider.model) - setContextLimit(nextContextLimit) + setContextLimit(provider.contextWindow) setSessionOptionsState((prev) => ({ ...prev, sessionId: randomUUID(), providerName: provider.name, - contextWindow: nextContextLimit, - autoCompactThresholdPercent: loaded.config.auto_compact_threshold_percent, + contextWindow: provider.contextWindow, + autoCompactThresholdPercent: snapshot.autoCompactThresholdPercent, })) setSetupPending(false) - appendSystemMessage('Setup', `Config saved to ${loaded.configPath}`) + appendSystemMessage('Setup', `Config saved to ${snapshot.configPath}`) } catch (err) { appendSystemMessage( 'Setup', @@ -939,8 +950,7 @@ export function App({ busy={busy} history={inputHistory} cwd={cwd} - sessionsDir={sessionsDir} - currentSessionFile={sessionLogPath ?? undefined} + currentSessionId={session?.id ?? sessionOptionsState.sessionId} providers={providersState} configPath={configPath} providerName={currentProvider} diff --git a/packages/tui/src/bottom_pane/Composer.tsx b/packages/tui/src/bottom_pane/Composer.tsx index 02d9ee5..b42ac16 100644 --- a/packages/tui/src/bottom_pane/Composer.tsx +++ b/packages/tui/src/bottom_pane/Composer.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box, Text, useInput, useStdout } from 'ink' -import type { MCPServerConfig, ProviderConfig } from '@memo/core' +import type { MCPServerConfig, ProviderConfig } from '../http/api_types' import { resolveSlashCommand, SLASH_SPECS } from '../slash/registry' import type { SlashContext } from '../slash/types' import { getFileSuggestions } from '../controllers/file_suggestions' @@ -45,8 +45,7 @@ type ComposerProps = { busy: boolean history: string[] cwd: string - sessionsDir: string - currentSessionFile?: string + currentSessionId?: string providers: ProviderConfig[] configPath: string providerName: string @@ -180,8 +179,7 @@ function formatTimestamp(ts: number): string { type SuggestionBuildInput = { trigger: Trigger cwd: string - sessionsDir: string - currentSessionFile?: string + currentSessionId?: string providers: ProviderConfig[] toolPermissionMode: ToolPermissionMode } @@ -247,8 +245,7 @@ function buildSlashSuggestions(keyword: string): SuggestionBuildResult { async function buildSuggestionsForTrigger({ trigger, cwd, - sessionsDir, - currentSessionFile, + currentSessionId, providers, toolPermissionMode, }: SuggestionBuildInput): Promise { @@ -272,10 +269,9 @@ async function buildSuggestionsForTrigger({ case 'history': { const entries = await loadSessionHistoryEntries({ - sessionsDir, cwd, keyword: trigger.keyword, - activeSessionFile: currentSessionFile, + activeSessionId: currentSessionId, }) const items: SuggestionRecord[] = entries.map((entry) => ({ id: entry.id, @@ -304,8 +300,7 @@ export const Composer = memo(function Composer({ busy, history, cwd, - sessionsDir, - currentSessionFile, + currentSessionId, providers, configPath, providerName, @@ -459,8 +454,7 @@ export const Composer = memo(function Composer({ const { mode: nextMode, items: nextItems } = await buildSuggestionsForTrigger({ trigger, cwd, - sessionsDir, - currentSessionFile, + currentSessionId, providers, toolPermissionMode, }) @@ -480,15 +474,7 @@ export const Composer = memo(function Composer({ return () => { cancelled = true } - }, [ - trigger, - cwd, - sessionsDir, - currentSessionFile, - providers, - toolPermissionMode, - closeSuggestions, - ]) + }, [trigger, cwd, currentSessionId, providers, toolPermissionMode, closeSuggestions]) const applySuggestion = useCallback( (record?: SuggestionRecord) => { diff --git a/packages/tui/src/cli.tsx b/packages/tui/src/cli.tsx index 02ffe1c..1a6ef3a 100644 --- a/packages/tui/src/cli.tsx +++ b/packages/tui/src/cli.tsx @@ -1,34 +1,25 @@ // CLI entry: interactive/one-off modes with session management and logs. import { randomUUID } from 'node:crypto' -import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' import { render } from 'ink' -import { - loadMemoConfig, - resolveContextWindowForProvider, - writeMemoConfig, - selectProvider, - getSessionsDir, - type AgentSessionDeps, - type AgentSessionOptions, - type ChatMessage, - type MemoConfig, -} from '@memo/core' import { App } from './App' import { findLocalPackageInfoSync } from './version' import { runMcpCommand } from './mcp' import { createHttpAgentSession } from './http/http_agent_session' -import { parseHistoryLog, type ParsedHistoryLog } from './controllers/history_parser' +import { parseSessionDetail, type ParsedHistoryLog } from './controllers/history_parser' import { loadSessionHistoryEntries } from './controllers/session_history' import { parseArgs, type ParsedArgs } from './cli_args' import { routeCli } from './cli_router' +import { closeSharedCoreServerClient, withSharedCoreServerClient } from './http/shared_core_client' +import { runWebCommand } from './web/run_web_command' +import type { AgentSessionDeps, AgentSessionOptions, ChatMessage } from './http/api_types' async function ensureProviderConfig(mode: 'plain' | 'tui') { - const loaded = await loadMemoConfig() - if (!loaded.needsSetup) return loaded + const snapshot = await withSharedCoreServerClient((client) => client.getConfig()) + if (!snapshot.needsSetup) return snapshot - const defaultProvider = loaded.config.providers[0] + const defaultProvider = snapshot.providers[0] const envCandidates = [ defaultProvider?.env_api_key, 'OPENAI_API_KEY', @@ -38,15 +29,21 @@ async function ensureProviderConfig(mode: 'plain' | 'tui') { const hasEnvKey = envCandidates.some((key) => Boolean(process.env[key])) if (defaultProvider && hasEnvKey) { - await writeMemoConfig(loaded.configPath, loaded.config) + await withSharedCoreServerClient((client) => + client.patchConfig({ + current_provider: defaultProvider.name, + providers: [defaultProvider], + }), + ) + const next = await withSharedCoreServerClient((client) => client.getConfig()) console.log( - `Detected API key in env. Wrote default provider (${defaultProvider.name}) to ${loaded.configPath}`, + `Detected API key in env. Wrote default provider (${defaultProvider.name}) to ${next.configPath}`, ) - return { ...loaded, needsSetup: false } + return next } if (mode === 'tui') { - return loaded + return snapshot } const rl = createInterface({ input, output }) @@ -65,38 +62,37 @@ async function ensureProviderConfig(mode: 'plain' | 'tui') { 'https://api.deepseek.com', ) - const config: MemoConfig = { - current_provider: name, - providers: [ - { - name, - env_api_key: envKey, - model, - base_url: baseUrl || undefined, - }, - ], - } - await writeMemoConfig(loaded.configPath, config) - console.log(`Config written to ${loaded.configPath}\n`) - return { ...loaded, config, needsSetup: false } + await withSharedCoreServerClient((client) => + client.patchConfig({ + current_provider: name, + providers: [ + { + name, + env_api_key: envKey, + model, + base_url: baseUrl || undefined, + }, + ], + }), + ) + const next = await withSharedCoreServerClient((client) => client.getConfig()) + console.log(`Config written to ${next.configPath}\n`) + return next } finally { rl.close() } } -async function loadPreviousSession( - sessionsDir: string, - cwd: string, -): Promise { +async function loadPreviousSession(cwd: string): Promise { const entries = await loadSessionHistoryEntries({ - sessionsDir, cwd, limit: 1, }) const latest = entries[0] if (!latest) return null - const raw = await readFile(latest.sessionFile, 'utf8') - return parseHistoryLog(raw) + + const detail = await withSharedCoreServerClient((client) => client.getSessionDetail(latest.id)) + return parseSessionDetail(detail) } async function restoreHistoryMessages( @@ -117,42 +113,34 @@ async function restoreHistoryMessages( } async function runPlainMode(parsed: ParsedArgs) { - const loaded = await ensureProviderConfig('plain') - const provider = selectProvider(loaded.config) - const contextWindow = resolveContextWindowForProvider(loaded.config, provider) + const snapshot = await ensureProviderConfig('plain') + const provider = snapshot.selectedProvider const sessionId = randomUUID() const sessionOptions: AgentSessionOptions = { sessionId, mode: 'interactive', - contextWindow, - autoCompactThresholdPercent: loaded.config.auto_compact_threshold_percent, - activeMcpServers: loaded.config.active_mcp_servers, + contextWindow: provider.contextWindow, + autoCompactThresholdPercent: snapshot.autoCompactThresholdPercent, + activeMcpServers: snapshot.activeMcpServers, dangerous: parsed.options.dangerous, } - const sessionsDir = getSessionsDir(loaded, sessionOptions) - const previousSession = parsed.options.prev - ? await loadPreviousSession(sessionsDir, process.cwd()) - : null + const previousSession = parsed.options.prev ? await loadPreviousSession(process.cwd()) : null if (parsed.options.prev && !previousSession) { console.error('No previous session found for current directory.') process.exitCode = 1 return } - // Show warning in dangerous mode if (parsed.options.dangerous) { console.log('⚠️ DANGEROUS MODE: All tool approvals are bypassed!') } const deps: AgentSessionDeps = { - // In non-dangerous mode, plain mode does not support interactive approval, so don't set requestApproval - // Returns error when tool needs approval requestApproval: parsed.options.dangerous ? undefined : (request) => { - // Plain mode cannot interact, deny directly console.log(`\n[approval required] ${request.toolName}: ${request.reason}`) - console.log(`[approval] Run with --dangerous to bypass approval`) + console.log('[approval] Run with --dangerous to bypass approval') return Promise.resolve('deny') }, hooks: { @@ -163,7 +151,7 @@ async function runPlainMode(parsed: ParsedArgs) { } }, onObservation: () => { - // Don't show results, only show tool call parameters + // Don't show results, only show tool call parameters. }, }, } @@ -205,29 +193,24 @@ async function runPlainMode(parsed: ParsedArgs) { } async function runInteractiveTui(parsed: ParsedArgs) { - const loaded = await ensureProviderConfig('tui') - const provider = selectProvider(loaded.config) - const contextWindow = resolveContextWindowForProvider(loaded.config, provider) + const snapshot = await ensureProviderConfig('tui') + const provider = snapshot.selectedProvider const sessionId = randomUUID() const sessionOptions: AgentSessionOptions = { sessionId, mode: 'interactive', - contextWindow, - autoCompactThresholdPercent: loaded.config.auto_compact_threshold_percent, - activeMcpServers: loaded.config.active_mcp_servers, + contextWindow: provider.contextWindow, + autoCompactThresholdPercent: snapshot.autoCompactThresholdPercent, + activeMcpServers: snapshot.activeMcpServers, dangerous: parsed.options.dangerous, } - const sessionsDir = getSessionsDir(loaded, sessionOptions) - const previousSession = parsed.options.prev - ? await loadPreviousSession(sessionsDir, process.cwd()) - : null + const previousSession = parsed.options.prev ? await loadPreviousSession(process.cwd()) : null if (parsed.options.prev && !previousSession) { console.error('No previous session found for current directory.') process.exitCode = 1 return } - // 危险模式下显示警告 if (parsed.options.dangerous) { console.log('⚠️ DANGEROUS MODE: All tool approvals are bypassed!') console.log(' Use with caution.\n') @@ -238,14 +221,13 @@ async function runInteractiveTui(parsed: ParsedArgs) { sessionOptions={sessionOptions} providerName={provider.name} model={provider.model} - configPath={loaded.configPath} - mcpServers={loaded.config.mcp_servers ?? {}} + configPath={snapshot.configPath} + mcpServers={snapshot.mcpServers} cwd={process.cwd()} - sessionsDir={sessionsDir} - providers={loaded.config.providers} - modelProfiles={loaded.config.model_profiles} + providers={snapshot.providers} + modelProfiles={snapshot.modelProfiles} dangerous={parsed.options.dangerous} - needsSetup={loaded.needsSetup} + needsSetup={snapshot.needsSetup} initialHistory={previousSession ?? undefined} />, { @@ -257,32 +239,37 @@ async function runInteractiveTui(parsed: ParsedArgs) { } async function main() { - const argv = process.argv.slice(2) - const route = routeCli(argv) - if (route.kind === 'subcommand') { - if (route.name === 'mcp') { - await runMcpCommand(route.args) + try { + const argv = process.argv.slice(2) + const route = routeCli(argv) + if (route.kind === 'subcommand') { + if (route.name === 'mcp') { + await runMcpCommand(route.args) + return + } + if (route.name === 'web') { + await runWebCommand(route.args) + return + } + } + + const parsed = parseArgs(route.args) + if (parsed.options.showVersion) { + const info = findLocalPackageInfoSync() + const version = info?.version ?? 'unknown' + console.log(version) return } - if (route.name === 'web') { - const { runWebCommand } = await import('./web/run_web_command') - await runWebCommand(route.args) + + const isInteractive = process.stdin.isTTY && process.stdout.isTTY + if (parsed.options.once || !isInteractive) { + await runPlainMode(parsed) return } + await runInteractiveTui(parsed) + } finally { + await closeSharedCoreServerClient() } - const parsed = parseArgs(route.args) - if (parsed.options.showVersion) { - const info = findLocalPackageInfoSync() - const version = info?.version ?? 'unknown' - console.log(version) - return - } - const isInteractive = process.stdin.isTTY && process.stdout.isTTY - if (parsed.options.once || !isInteractive) { - await runPlainMode(parsed) - return - } - await runInteractiveTui(parsed) } void main() diff --git a/packages/tui/src/controllers/file_suggestions.ts b/packages/tui/src/controllers/file_suggestions.ts index 67bbf5a..f964d52 100644 --- a/packages/tui/src/controllers/file_suggestions.ts +++ b/packages/tui/src/controllers/file_suggestions.ts @@ -1,22 +1,26 @@ -import { - getFileSuggestions as getCoreFileSuggestions, - invalidateFileSuggestionCache as invalidateCoreFileSuggestionCache, - normalizePath as normalizeCorePath, - type FileSuggestion as CoreFileSuggestion, - type FileSuggestionRequest as CoreFileSuggestionRequest, -} from '@memo/core/runtime/file_suggestions' +import { withSharedCoreServerClient } from '../http/shared_core_client' import type { FileSuggestion, FileSuggestionRequest } from './types' export function normalizePath(input: string): string { - return normalizeCorePath(input) + return input.replace(/\\/g, '/') } export async function getFileSuggestions(req: FileSuggestionRequest): Promise { - return getCoreFileSuggestions(req as CoreFileSuggestionRequest) as Promise -} + const response = await withSharedCoreServerClient((client) => + client.suggestFiles({ + query: req.query, + workspaceCwd: req.cwd, + limit: req.limit, + maxDepth: req.maxDepth, + maxEntries: req.maxEntries, + respectGitIgnore: req.respectGitIgnore, + ignoreGlobs: req.ignoreGlobs, + }), + ) -export function invalidateFileSuggestionCache(cwd?: string): void { - invalidateCoreFileSuggestionCache(cwd) + return response.items } -export type { CoreFileSuggestion, CoreFileSuggestionRequest } +export function invalidateFileSuggestionCache(_cwd?: string): void { + // Suggestions are served by core HTTP API; cache invalidation is handled server-side. +} diff --git a/packages/tui/src/controllers/history_parser.test.ts b/packages/tui/src/controllers/history_parser.test.ts index 211f17f..448bfa4 100644 --- a/packages/tui/src/controllers/history_parser.test.ts +++ b/packages/tui/src/controllers/history_parser.test.ts @@ -1,48 +1,64 @@ import assert from 'node:assert' import { describe, test } from 'vitest' +import type { SessionDetail } from '../http/api_types' import { TOOL_STATUS } from '../types' -import { parseHistoryLog } from './history_parser' +import { parseSessionDetail } from './history_parser' -function line(event: Record): string { - return JSON.stringify({ - ts: new Date().toISOString(), - sessionId: 'session-1', - ...event, - }) -} - -describe('parseHistoryLog', () => { - test('maps core history detail to tui timeline model', () => { - const raw = [ - line({ type: 'session_start', meta: { cwd: '/tmp/demo' } }), - line({ type: 'turn_start', turn: 1, content: 'plan this task' }), - line({ type: 'assistant', turn: 1, step: 0, content: 'thinking...' }), - line({ - type: 'action', - turn: 1, - step: 0, - meta: { - tool: 'read_file', - input: { path: 'README.md' }, - thinking: 'need context', +describe('parseSessionDetail', () => { + test('maps session detail to tui timeline model', () => { + const detail: SessionDetail = { + id: 'history-1', + sessionId: 'session-1', + filePath: '/tmp/history-1.jsonl', + title: 'plan this task', + project: 'demo', + workspaceId: 'ws-1', + cwd: '/tmp/demo', + date: { + day: '2026-03-03', + startedAt: '2026-03-03T00:00:00.000Z', + updatedAt: '2026-03-03T00:01:00.000Z', + }, + status: 'idle', + turnCount: 1, + tokenUsage: { + prompt: 10, + completion: 12, + total: 22, + }, + toolUsage: { + total: 1, + success: 1, + failed: 0, + denied: 0, + cancelled: 0, + }, + summary: 'Session summary', + turns: [ + { + turn: 1, + input: 'plan this task', + finalText: 'done', + status: 'ok', + steps: [ + { + step: 0, + assistantText: 'thinking...', + thinking: 'need context', + action: { + tool: 'read_file', + input: { path: 'README.md' }, + }, + observation: 'loaded', + resultStatus: 'success', + }, + ], }, - }), - line({ - type: 'observation', - turn: 1, - step: 0, - content: 'loaded', - meta: { status: 'success' }, - }), - line({ - type: 'final', - turn: 1, - content: 'done', - meta: { status: 'ok' }, - }), - ].join('\n') + ], + events: [], + } - const parsed = parseHistoryLog(raw) + const parsed = parseSessionDetail(detail) assert.strictEqual(parsed.messages.length, 2) assert.strictEqual(parsed.messages[0]?.role, 'user') assert.strictEqual(parsed.messages[0]?.content, 'plan this task') diff --git a/packages/tui/src/controllers/history_parser.ts b/packages/tui/src/controllers/history_parser.ts index 88ca050..64d8c6b 100644 --- a/packages/tui/src/controllers/history_parser.ts +++ b/packages/tui/src/controllers/history_parser.ts @@ -1,9 +1,9 @@ -import { - parseHistoryLogToSessionDetail, - type ChatMessage, - type SessionTurnDetail, - type SessionTurnStep, -} from '@memo/core' +import type { + ChatMessage, + SessionDetail, + SessionTurnDetail, + SessionTurnStep, +} from '../http/api_types' import { TOOL_STATUS, type StepView, type TurnView } from '../types' export type ParsedHistoryLog = { @@ -52,8 +52,7 @@ function toTurnView(turn: SessionTurnDetail, sequence: number, turnIndex: number } } -export function parseHistoryLog(raw: string): ParsedHistoryLog { - const detail = parseHistoryLogToSessionDetail(raw, 'history.log') +export function parseSessionDetail(detail: SessionDetail): ParsedHistoryLog { const orderedTurns = [...detail.turns].sort((left, right) => left.turn - right.turn) const messages: ChatMessage[] = [] diff --git a/packages/tui/src/controllers/session_history.test.ts b/packages/tui/src/controllers/session_history.test.ts index b2de855..5de42bd 100644 --- a/packages/tui/src/controllers/session_history.test.ts +++ b/packages/tui/src/controllers/session_history.test.ts @@ -1,68 +1,102 @@ -import assert from 'node:assert' -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, test } from 'vitest' -import { loadSessionHistoryEntries } from './session_history' +import { describe, expect, test, vi } from 'vitest' -function buildHistoryLine(event: Record): string { - return JSON.stringify({ - ts: new Date().toISOString(), - sessionId: 'session-1', - ...event, - }) -} +const { withSharedCoreServerClientMock } = vi.hoisted(() => ({ + withSharedCoreServerClientMock: vi.fn(), +})) -async function writeSessionFile( - sessionsDir: string, - fileName: string, - lines: string[], -): Promise { - await mkdir(sessionsDir, { recursive: true }) - const filePath = join(sessionsDir, fileName) - await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8') - return filePath -} +vi.mock('../http/shared_core_client', () => ({ + withSharedCoreServerClient: withSharedCoreServerClientMock, +})) -describe('loadSessionHistoryEntries', () => { - test('prefers session_title over first prompt for history display', async () => { - const sessionsDir = join(tmpdir(), `memo-session-history-${Date.now()}-title`) - const cwd = join(tmpdir(), `memo-session-cwd-${Date.now()}`) - await mkdir(cwd, { recursive: true }) +import { loadSessionHistoryEntries } from './session_history' - try { - await writeSessionFile(sessionsDir, 'rollout-title.jsonl', [ - buildHistoryLine({ type: 'session_start', meta: { cwd } }), - buildHistoryLine({ type: 'turn_start', content: 'Help me create an API' }), - buildHistoryLine({ type: 'session_title', content: 'Express.js REST API' }), - ]) +describe('loadSessionHistoryEntries', () => { + test('prefers title and maps API response', async () => { + withSharedCoreServerClientMock.mockImplementation(async (runner) => + runner({ + listSessions: vi.fn().mockResolvedValue({ + items: [ + { + id: 'history-1', + sessionId: 'session-1', + filePath: '/tmp/session-1.jsonl', + title: 'Express.js REST API', + project: 'demo', + workspaceId: 'ws-1', + cwd: '/tmp/demo', + date: { + day: '2026-03-03', + startedAt: '2026-03-03T00:00:00.000Z', + updatedAt: '2026-03-03T00:01:00.000Z', + }, + status: 'idle', + turnCount: 1, + tokenUsage: { prompt: 1, completion: 1, total: 2 }, + toolUsage: { + total: 0, + success: 0, + failed: 0, + denied: 0, + cancelled: 0, + }, + }, + ], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + }), + }), + ) - const entries = await loadSessionHistoryEntries({ sessionsDir, cwd }) - assert.strictEqual(entries.length, 1) - assert.strictEqual(entries[0]?.input, 'Express.js REST API') - } finally { - await rm(sessionsDir, { recursive: true, force: true }) - await rm(cwd, { recursive: true, force: true }) - } + const entries = await loadSessionHistoryEntries({ cwd: '/tmp/demo', limit: 10 }) + expect(entries).toHaveLength(1) + expect(entries[0]?.input).toBe('Express.js REST API') }) - test('falls back to first prompt when session_title is missing', async () => { - const sessionsDir = join(tmpdir(), `memo-session-history-${Date.now()}-prompt`) - const cwd = join(tmpdir(), `memo-session-cwd-${Date.now()}`) - await mkdir(cwd, { recursive: true }) - - try { - await writeSessionFile(sessionsDir, 'rollout-prompt.jsonl', [ - buildHistoryLine({ type: 'session_start', meta: { cwd } }), - buildHistoryLine({ type: 'turn_start', content: 'Design a postgres schema' }), - ]) + test('filters out active session id', async () => { + withSharedCoreServerClientMock.mockImplementation(async (runner) => + runner({ + listSessions: vi.fn().mockResolvedValue({ + items: [ + { + id: 'history-1', + sessionId: 'active-session', + filePath: '/tmp/session-1.jsonl', + title: 'Current session', + project: 'demo', + workspaceId: 'ws-1', + cwd: '/tmp/demo', + date: { + day: '2026-03-03', + startedAt: '2026-03-03T00:00:00.000Z', + updatedAt: '2026-03-03T00:01:00.000Z', + }, + status: 'idle', + turnCount: 1, + tokenUsage: { prompt: 1, completion: 1, total: 2 }, + toolUsage: { + total: 0, + success: 0, + failed: 0, + denied: 0, + cancelled: 0, + }, + }, + ], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + }), + }), + ) - const entries = await loadSessionHistoryEntries({ sessionsDir, cwd }) - assert.strictEqual(entries.length, 1) - assert.strictEqual(entries[0]?.input, 'Design a postgres schema') - } finally { - await rm(sessionsDir, { recursive: true, force: true }) - await rm(cwd, { recursive: true, force: true }) - } + const entries = await loadSessionHistoryEntries({ + cwd: '/tmp/demo', + activeSessionId: 'active-session', + limit: 10, + }) + expect(entries).toHaveLength(0) }) }) diff --git a/packages/tui/src/controllers/session_history.ts b/packages/tui/src/controllers/session_history.ts index dcf774b..df21b17 100644 --- a/packages/tui/src/controllers/session_history.ts +++ b/packages/tui/src/controllers/session_history.ts @@ -1,33 +1,12 @@ -import { HistoryIndex, type SessionListItem } from '@memo/core' -import { basename, resolve } from 'node:path' +import type { SessionListItem } from '../http/api_types' +import { withSharedCoreServerClient } from '../http/shared_core_client' export type SessionHistoryEntry = { id: string + sessionId: string cwd: string input: string ts: number - sessionFile: string -} - -const historyIndexCache = new Map() - -function normalizeCwd(input: string): string { - const normalized = resolve(input) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function sessionFileId(filePath: string): string { - return resolve(filePath) -} - -function getHistoryIndex(sessionsDir: string): HistoryIndex { - const normalizedDir = resolve(sessionsDir) - const existing = historyIndexCache.get(normalizedDir) - if (existing) return existing - - const index = new HistoryIndex({ sessionsDir: normalizedDir }) - historyIndexCache.set(normalizedDir, index) - return index } function parseTimestamp(value: string): number { @@ -38,52 +17,43 @@ function parseTimestamp(value: string): number { function resolveEntryTitle(summary: SessionListItem): string { const title = summary.title?.trim() if (title) return title - return basename(summary.filePath).replace(/\.jsonl$/i, '') + return summary.sessionId || summary.id } export async function loadSessionHistoryEntries(options: { - sessionsDir: string cwd: string keyword?: string - activeSessionFile?: string + activeSessionId?: string limit?: number }): Promise { const limit = options.limit ?? 10 if (limit <= 0) return [] - const index = getHistoryIndex(options.sessionsDir) - const summaries = await index.getAllSummaries() - const normalizedCwd = normalizeCwd(options.cwd) - const normalizedActive = options.activeSessionFile - ? sessionFileId(options.activeSessionFile) - : null - const keyword = options.keyword?.trim().toLowerCase() + const pageSize = Math.max(limit * 3, 20) + const response = await withSharedCoreServerClient((client) => + client.listSessions({ + page: 1, + pageSize, + sortBy: 'updatedAt', + order: 'desc', + workspaceCwd: options.cwd, + q: options.keyword?.trim() || undefined, + }), + ) - const seen = new Set() + const activeSessionId = options.activeSessionId?.trim() const entries: SessionHistoryEntry[] = [] - const sorted = [...summaries].sort((left, right) => - right.date.updatedAt.localeCompare(left.date.updatedAt), - ) - - for (const summary of sorted) { + for (const summary of response.items) { if (entries.length >= limit) break - if (normalizeCwd(summary.cwd) !== normalizedCwd) continue - - const sessionFile = sessionFileId(summary.filePath) - if (normalizedActive && sessionFile === normalizedActive) continue - if (seen.has(sessionFile)) continue - - const input = resolveEntryTitle(summary) - if (keyword && !input.toLowerCase().includes(keyword)) continue + if (activeSessionId && summary.sessionId === activeSessionId) continue - seen.add(sessionFile) entries.push({ - id: sessionFile, - cwd: options.cwd, - input, + id: summary.id, + sessionId: summary.sessionId, + cwd: summary.cwd, + input: resolveEntryTitle(summary), ts: parseTimestamp(summary.date.updatedAt), - sessionFile, }) } diff --git a/packages/tui/src/http/api_types.ts b/packages/tui/src/http/api_types.ts new file mode 100644 index 0000000..ddeb956 --- /dev/null +++ b/packages/tui/src/http/api_types.ts @@ -0,0 +1,192 @@ +import type { ApprovalDecision, ApprovalRequest } from '@memo/tools/approval' +import type { ToolActionStatus } from '@memo/tools/orchestrator' +import type { + ChatMessage, + TokenUsageSummary, + ToolPermissionMode, + SessionTurnDetail, + SessionTurnStep, +} from '@memo-code/types' + +export type * from '@memo-code/types' + +export type SessionMode = 'interactive' +export type TurnStatus = 'ok' | 'error' | 'prompt_limit' | 'cancelled' +export type ContextUsagePhase = 'turn_start' | 'step_start' | 'post_compact' +export type CompactReason = 'auto' | 'manual' +export type CompactStatus = 'success' | 'failed' | 'skipped' + +export type TokenUsage = TokenUsageSummary + +export type ToolAction = { + tool: string + input: unknown +} + +export type AgentStepTrace = { + index: number + assistantText: string + parsed: { + action?: ToolAction + final?: string + thinking?: string + } + observation?: string + tokenUsage: TokenUsage +} + +export type CompactResult = { + reason: CompactReason + status: CompactStatus + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string +} + +export type TurnResult = { + finalText: string + steps: AgentStepTrace[] + status: TurnStatus + errorMessage?: string + tokenUsage: TokenUsage +} + +export type AgentSessionOptions = { + sessionId?: string + mode?: SessionMode + historyDir?: string + providerName?: string + tokenizerModel?: string + cwd?: string + warnPromptTokens?: number + contextWindow?: number + autoCompactThresholdPercent?: number + activeMcpServers?: string[] + dangerous?: boolean + toolPermissionMode?: ToolPermissionMode +} + +export type TurnStartHookPayload = { + sessionId: string + turn: number + input: string + promptTokens?: number + history: ChatMessage[] +} + +export type ActionHookPayload = { + sessionId: string + turn: number + step: number + action: ToolAction + parallelActions?: ToolAction[] + thinking?: string + history: ChatMessage[] +} + +export type ObservationHookPayload = { + sessionId: string + turn: number + step: number + tool: string + observation: string + resultStatus?: ToolActionStatus + parallelResultStatuses?: ToolActionStatus[] + history: ChatMessage[] +} + +export type FinalHookPayload = { + sessionId: string + turn: number + step?: number + finalText: string + status: TurnStatus + errorMessage?: string + tokenUsage?: TokenUsage + turnUsage: TokenUsage + steps: AgentStepTrace[] +} + +export type ContextUsageHookPayload = { + sessionId: string + turn: number + step: number + promptTokens: number + contextWindow: number + thresholdTokens: number + usagePercent: number + phase: ContextUsagePhase +} + +export type ContextCompactedHookPayload = { + sessionId: string + turn: number + step: number + reason: CompactReason + status: CompactStatus + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string +} + +export type ApprovalHookPayload = { + sessionId: string + turn: number + step: number + request: ApprovalRequest +} + +export type ApprovalResponseHookPayload = { + sessionId: string + turn: number + step: number + fingerprint: string + decision: ApprovalDecision +} + +export type TitleGeneratedHookPayload = { + sessionId: string + turn: number + title: string + originalPrompt: string +} + +export type AgentHooks = { + onTurnStart?: (payload: TurnStartHookPayload) => Promise | void + onContextUsage?: (payload: ContextUsageHookPayload) => Promise | void + onContextCompacted?: (payload: ContextCompactedHookPayload) => Promise | void + onAction?: (payload: ActionHookPayload) => Promise | void + onObservation?: (payload: ObservationHookPayload) => Promise | void + onFinal?: (payload: FinalHookPayload) => Promise | void + onApprovalRequest?: (payload: ApprovalHookPayload) => Promise | void + onApprovalResponse?: (payload: ApprovalResponseHookPayload) => Promise | void + onTitleGenerated?: (payload: TitleGeneratedHookPayload) => Promise | void +} + +export type AgentSessionDeps = { + onAssistantStep?: (content: string, step: number) => void + hooks?: AgentHooks + requestApproval?: (request: ApprovalRequest) => Promise +} + +export type AgentSession = { + title?: string + id: string + mode: SessionMode + history: ChatMessage[] + historyFilePath?: string + runTurn: (input: string) => Promise + cancelCurrentTurn?: (reason?: string) => void + listToolNames?: () => string[] + compactHistory: (reason?: CompactReason) => Promise + close: () => Promise +} + +export type ParsedSessionTurnDetail = SessionTurnDetail +export type ParsedSessionTurnStep = SessionTurnStep diff --git a/packages/tui/src/http/core_server_client.ts b/packages/tui/src/http/core_server_client.ts index 272ebc1..22ceb3b 100644 --- a/packages/tui/src/http/core_server_client.ts +++ b/packages/tui/src/http/core_server_client.ts @@ -1,20 +1,24 @@ -import { randomUUID } from 'node:crypto' -import { - startCoreHttpServer, - type ChatMessage, - type CoreHttpServerHandle, - type LiveSessionState, - type SseEventEnvelope, - type ToolPermissionMode, -} from '@memo/core' - -type ApiEnvelope = - | { success: true; data: T; meta: { requestId: string; timestamp: string } } - | { - success: false - error: { code: string; message: string; details?: unknown } - meta: { requestId: string; timestamp: string; path?: string } - } +import type { + ApiEnvelope, + ChatMessage, + ConfigSnapshot, + FileSuggestion, + LiveSessionState, + McpServerRecord, + ProviderConfig, + SessionDetail, + SessionListResponse, + SseEventEnvelope, + ToolPermissionMode, + UpdateConfigRequest, +} from './api_types' +import { ensureCoreServerProcess } from './core_server_process' + +type CoreHttpServerHandle = { + url: string + openApiSpecPath: string + close: () => Promise +} type CreateSessionRequest = { sessionId?: string @@ -42,26 +46,20 @@ type CompactSessionResult = { keptMessages: number } -type TurnFinalEvent = { - turn: number - step?: number - finalText: string - status: string - errorMessage?: string - turnUsage?: { - prompt: number - completion: number - total: number - } - tokenUsage?: { - prompt: number - completion: number - total: number - } -} - type ApprovalDecision = 'once' | 'session' | 'deny' +export type ListSessionsQuery = { + page?: number + pageSize?: number + sortBy?: 'updatedAt' | 'startedAt' | 'project' | 'title' + order?: 'asc' | 'desc' + project?: string + workspaceCwd?: string + dateFrom?: string + dateTo?: string + q?: string +} + function resolveMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message return String(error || 'unknown error') @@ -81,6 +79,22 @@ function assertSuccessEnvelope(payload: unknown): T { return envelope.data } +async function readErrorMessage(response: Response, fallback: string): Promise { + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + return `${fallback} (${response.status})` + } + try { + const payload = (await response.json()) as ApiEnvelope + if (payload && typeof payload === 'object' && payload.success === false && payload.error) { + return payload.error.message || `${fallback} (${response.status})` + } + } catch { + // Ignore invalid JSON response. + } + return `${fallback} (${response.status})` +} + function parseSseFrame(frame: string): SseEventEnvelope | null { let event: string | undefined const dataLines: string[] = [] @@ -167,14 +181,26 @@ export class CoreServerClient { body: JSON.stringify({ password }), }) if (!response.ok) { - throw new Error(`Login failed (${response.status})`) + throw new Error(await readErrorMessage(response, 'Login failed')) } const payload = assertSuccessEnvelope<{ accessToken: string }>(await response.json()) return new CoreServerClient(baseUrl, payload.accessToken) } + async getConfig(): Promise { + return this.getJson('/api/config') + } + + async patchConfig(input: UpdateConfigRequest): Promise { + return this.patchJson('/api/config', input) + } + + async listProviders(): Promise<{ items: ProviderConfig[] }> { + return this.getJson('/api/chat/sessions/providers') + } + async createSession(input: CreateSessionRequest): Promise { - return this.postJson('/api/chat/sessions', input) + return this.postJson('/api/chat/sessions', input) } async restoreHistory( @@ -219,6 +245,65 @@ export class CoreServerClient { return this.postJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}/compact`, {}) } + async suggestFiles(input: { + query: string + sessionId?: string + workspaceCwd?: string + limit?: number + maxDepth?: number + maxEntries?: number + respectGitIgnore?: boolean + ignoreGlobs?: string[] + }): Promise<{ items: FileSuggestion[] }> { + return this.postJson('/api/chat/files/suggest', input) + } + + async listSessions(query: ListSessionsQuery = {}): Promise { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + searchParams.set(key, String(value)) + } + const suffix = searchParams.size > 0 ? `?${searchParams.toString()}` : '' + return this.getJson(`/api/sessions${suffix}`) + } + + async getSessionDetail(sessionId: string): Promise { + return this.getJson(`/api/sessions/${encodeURIComponent(sessionId)}`) + } + + async listMcpServers(): Promise<{ items: McpServerRecord[] }> { + return this.getJson('/api/mcp/servers') + } + + async getMcpServer(name: string): Promise { + return this.getJson(`/api/mcp/servers/${encodeURIComponent(name)}`) + } + + async createMcpServer(name: string, config: unknown): Promise<{ created: true }> { + return this.postJson('/api/mcp/servers', { name, config }) + } + + async updateMcpServer(name: string, config: unknown): Promise<{ updated: true }> { + return this.putJson(`/api/mcp/servers/${encodeURIComponent(name)}`, { config }) + } + + async removeMcpServer(name: string): Promise<{ deleted: true }> { + return this.deleteJson(`/api/mcp/servers/${encodeURIComponent(name)}`) + } + + async loginMcpServer(name: string, scopes?: string[]): Promise<{ loggedIn: true }> { + return this.postJson(`/api/mcp/servers/${encodeURIComponent(name)}/login`, { scopes }) + } + + async logoutMcpServer(name: string): Promise<{ loggedOut: true }> { + return this.postJson(`/api/mcp/servers/${encodeURIComponent(name)}/logout`, {}) + } + + async setActiveMcpServers(names: string[]): Promise<{ active: string[] }> { + return this.postJson('/api/mcp/active', { names }) + } + subscribeSessionEvents( sessionId: string, onEvent: (event: SseEventEnvelope) => Promise | void, @@ -234,7 +319,7 @@ export class CoreServerClient { }, ) if (!response.ok) { - throw new Error(`SSE subscribe failed (${response.status})`) + throw new Error(await readErrorMessage(response, 'SSE subscribe failed')) } if (!response.body) { throw new Error('SSE stream body is empty') @@ -261,9 +346,39 @@ export class CoreServerClient { body: JSON.stringify(body), }) if (!response.ok) { - throw new Error(`${path} failed (${response.status})`) + throw new Error(await readErrorMessage(response, `${path} failed`)) } - return assertSuccessEnvelope(await response.json()) + return assertSuccessEnvelope(await response.json()) + } + + private async putJson(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PUT', + headers: { + ...this.authHeaders(), + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error(await readErrorMessage(response, `${path} failed`)) + } + return assertSuccessEnvelope(await response.json()) + } + + private async patchJson(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PATCH', + headers: { + ...this.authHeaders(), + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error(await readErrorMessage(response, `${path} failed`)) + } + return assertSuccessEnvelope(await response.json()) } private async getJson(path: string): Promise { @@ -272,9 +387,9 @@ export class CoreServerClient { headers: this.authHeaders(), }) if (!response.ok) { - throw new Error(`${path} failed (${response.status})`) + throw new Error(await readErrorMessage(response, `${path} failed`)) } - return assertSuccessEnvelope(await response.json()) + return assertSuccessEnvelope(await response.json()) } private async deleteJson(path: string): Promise { @@ -283,9 +398,9 @@ export class CoreServerClient { headers: this.authHeaders(), }) if (!response.ok) { - throw new Error(`${path} failed (${response.status})`) + throw new Error(await readErrorMessage(response, `${path} failed`)) } - return assertSuccessEnvelope(await response.json()) + return assertSuccessEnvelope(await response.json()) } private authHeaders(): Record { @@ -299,32 +414,41 @@ export async function createEmbeddedCoreServerClient(options?: { host?: string memoHome?: string password?: string + preferredPort?: number + staticDir?: string + requireStaticDir?: boolean }): Promise<{ client: CoreServerClient server: CoreHttpServerHandle close: () => Promise }> { - const password = options?.password?.trim() || `memo-${randomUUID()}` - const server = await startCoreHttpServer({ - host: options?.host || '127.0.0.1', - port: 0, - password, + const processInfo = await ensureCoreServerProcess({ + host: options?.host, + preferredPort: options?.preferredPort, memoHome: options?.memoHome, + staticDir: options?.staticDir, + requireStaticDir: options?.requireStaticDir, }) try { - const client = await CoreServerClient.fromPassword(server.url, password) + const client = await CoreServerClient.fromPassword( + processInfo.baseUrl, + processInfo.password, + ) return { client, - server, + server: { + url: processInfo.baseUrl, + openApiSpecPath: '/api/openapi.json', + close: async () => { + // Shared daemon server is managed by launcher, not per-client lifecycle. + }, + }, close: async () => { - await server.close() + // Shared daemon server is managed by launcher, not per-client lifecycle. }, } } catch (error) { - await server.close() throw new Error(`Failed to initialize core server client: ${resolveMessage(error)}`) } } - -export type { TurnFinalEvent } diff --git a/packages/tui/src/http/core_server_process.test.ts b/packages/tui/src/http/core_server_process.test.ts new file mode 100644 index 0000000..03866ea --- /dev/null +++ b/packages/tui/src/http/core_server_process.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, test } from 'vitest' +import { + ensureCoreServerProcess, + readCoreServerProcessInfo, + stopCoreServerProcess, +} from './core_server_process' + +const testMemoHomes: string[] = [] +const testPathsToCleanup: string[] = [] + +function nextPreferredPort(seed: number): number { + const base = 5600 + (process.pid % 200) * 5 + return base + seed * 30 +} + +async function createMemoHome(prefix: string): Promise { + const home = await mkdtemp(join(tmpdir(), prefix)) + testMemoHomes.push(home) + return home +} + +async function createStaticDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)) + await writeFile(join(dir, 'index.html'), '\n', 'utf8') + testPathsToCleanup.push(dir) + return dir +} + +describe('ensureCoreServerProcess', () => { + afterEach(async () => { + for (const memoHome of testMemoHomes.splice(0)) { + await stopCoreServerProcess(memoHome).catch(() => { + // Best-effort cleanup between tests. + }) + await rm(memoHome, { recursive: true, force: true }).catch(() => { + // Ignore cleanup races. + }) + } + + for (const path of testPathsToCleanup.splice(0)) { + await rm(path, { recursive: true, force: true }).catch(() => { + // Ignore cleanup races. + }) + } + }) + + test('reuses existing process for the same memo home', async () => { + const memoHome = await createMemoHome('memo-core-process-reuse-') + const preferredPort = nextPreferredPort(1) + + const first = await ensureCoreServerProcess({ + memoHome, + host: '127.0.0.1', + preferredPort, + }) + expect(first.launched).toBe(true) + expect(first.pid).toBeGreaterThan(0) + + const second = await ensureCoreServerProcess({ + memoHome, + host: '127.0.0.1', + preferredPort, + }) + expect(second.launched).toBe(false) + expect(second.pid).toBe(first.pid) + expect(second.baseUrl).toBe(first.baseUrl) + + const state = await readCoreServerProcessInfo(memoHome) + expect(state?.pid).toBe(first.pid) + expect(state?.baseUrl).toBe(first.baseUrl) + }) + + test('restarts when required static dir changes', async () => { + const memoHome = await createMemoHome('memo-core-process-static-') + const staticA = await createStaticDir('memo-web-static-a-') + const staticB = await createStaticDir('memo-web-static-b-') + const preferredPort = nextPreferredPort(2) + + const first = await ensureCoreServerProcess({ + memoHome, + host: '127.0.0.1', + preferredPort, + staticDir: staticA, + requireStaticDir: true, + }) + expect(first.launched).toBe(true) + expect(resolve(first.staticDir ?? '')).toBe(resolve(staticA)) + + const second = await ensureCoreServerProcess({ + memoHome, + host: '127.0.0.1', + preferredPort, + staticDir: staticB, + requireStaticDir: true, + }) + expect(second.launched).toBe(true) + expect(resolve(second.staticDir ?? '')).toBe(resolve(staticB)) + }) +}) diff --git a/packages/tui/src/http/core_server_process.ts b/packages/tui/src/http/core_server_process.ts new file mode 100644 index 0000000..7feb26d --- /dev/null +++ b/packages/tui/src/http/core_server_process.ts @@ -0,0 +1,531 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { mkdir, open, readFile, rm, stat } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { createServer } from 'node:net' +import { dirname, join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const DEFAULT_HOST = '127.0.0.1' +const DEFAULT_PORT = 5494 +const START_TIMEOUT_MS = 15_000 +const LOCK_TIMEOUT_MS = 15_000 +const STALE_LOCK_MS = 30_000 + +type CoreServerState = { + version: 1 + pid: number + host: string + port: number + baseUrl: string + password: string + memoHome?: string + stateFile: string + staticDir?: string + startedAt: string +} + +export type CoreServerProcessInfo = { + baseUrl: string + password: string + pid: number + staticDir?: string + launched: boolean +} + +export type EnsureCoreServerProcessOptions = { + host?: string + preferredPort?: number + memoHome?: string + staticDir?: string + requireStaticDir?: boolean +} + +type LockHandle = { + release: () => Promise +} + +let ownsCleanupHook = false +const testOwnedPids = new Set() + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => { + setTimeout(resolveSleep, ms) + }) +} + +function resolveMemoHome(explicit?: string): string { + const input = explicit?.trim() || process.env.MEMO_HOME?.trim() + if (input) { + if (input === '~') { + return homedir() + } + if (input.startsWith('~/')) { + return resolve(join(homedir(), input.slice(2))) + } + return resolve(input) + } + + if (process.env.VITEST) { + return join(tmpdir(), `memo-test-home-${process.pid}`) + } + + return join(homedir(), '.memo') +} + +function resolveServerPaths(memoHome: string): { + runtimeDir: string + stateFile: string + lockFile: string +} { + const runtimeDir = join(memoHome, 'run') + return { + runtimeDir, + stateFile: join(runtimeDir, 'core-server.json'), + lockFile: join(runtimeDir, 'core-server.lock'), + } +} + +async function readServerState(stateFile: string): Promise { + try { + const raw = await readFile(stateFile, 'utf8') + if (!raw.trim()) return null + const parsed = JSON.parse(raw) as CoreServerState + if (!parsed || typeof parsed !== 'object') return null + if (typeof parsed.baseUrl !== 'string' || typeof parsed.password !== 'string') return null + if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) { + return null + } + return parsed + } catch { + return null + } +} + +function resolveMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + return String(error || 'unknown error') +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function terminatePid(pid: number): Promise { + if (!isPidAlive(pid)) return + + try { + process.kill(pid, 'SIGTERM') + } catch { + return + } + + const deadline = Date.now() + 2_500 + while (Date.now() < deadline) { + if (!isPidAlive(pid)) return + await sleep(100) + } + + try { + process.kill(pid, 'SIGKILL') + } catch { + // Best-effort kill. + } +} + +async function checkLogin(state: CoreServerState): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 1_500) + + try { + const response = await fetch(`${state.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ password: state.password }), + signal: controller.signal, + }) + + if (!response.ok) { + return false + } + + const payload = (await response.json()) as { + success?: boolean + data?: { accessToken?: string } + } + + return payload.success === true && typeof payload.data?.accessToken === 'string' + } catch { + return false + } finally { + clearTimeout(timer) + } +} + +async function isHealthyState(state: CoreServerState): Promise { + if (!isPidAlive(state.pid)) return false + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 1_500) + + try { + const response = await fetch(`${state.baseUrl}/api/openapi.json`, { + method: 'GET', + signal: controller.signal, + }) + + if (!response.ok) { + return false + } + } catch { + return false + } finally { + clearTimeout(timer) + } + + return checkLogin(state) +} + +function findMemoPackageRoot(startDir: string): string | null { + let current = resolve(startDir) + + while (true) { + const packageJson = join(current, 'package.json') + if (existsSync(packageJson)) { + try { + const parsed = JSON.parse(readFileSync(packageJson, 'utf8')) as { name?: string } + if (parsed.name === '@memo-code/memo') { + return current + } + } catch { + // Ignore invalid package.json while traversing upward. + } + } + + const parent = dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +function hasFile(path: string): boolean { + return existsSync(path) +} + +export function resolveWebStaticDir(explicitPath?: string): string | null { + const candidates: string[] = [] + if (explicitPath) { + candidates.push(resolve(explicitPath)) + } + if (process.env.MEMO_WEB_STATIC_DIR?.trim()) { + candidates.push(resolve(process.env.MEMO_WEB_STATIC_DIR.trim())) + } + + const runtimeDir = dirname(fileURLToPath(import.meta.url)) + const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) + + if (packageRoot) { + candidates.push(join(packageRoot, 'dist/web/ui')) + candidates.push(join(packageRoot, 'packages/web-ui/dist')) + } + + candidates.push(join(process.cwd(), 'dist/web/ui')) + candidates.push(join(process.cwd(), 'packages/web-ui/dist')) + + for (const candidate of candidates) { + if (hasFile(join(candidate, 'index.html'))) { + return candidate + } + } + + return null +} + +async function acquireLock(lockFile: string): Promise { + const startedAt = Date.now() + + while (true) { + try { + const handle = await open(lockFile, 'wx', 0o600) + return { + release: async () => { + try { + await handle.close() + } finally { + await rm(lockFile, { force: true }) + } + }, + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'EEXIST') { + throw error + } + + try { + const info = await stat(lockFile) + if (Date.now() - info.mtimeMs > STALE_LOCK_MS) { + await rm(lockFile, { force: true }) + continue + } + } catch { + // Ignore stat/rm race. + } + + if (Date.now() - startedAt > LOCK_TIMEOUT_MS) { + throw new Error('Timed out waiting for core-server launch lock') + } + + await sleep(120) + } + } +} + +async function isPortAvailable(host: string, port: number): Promise { + return new Promise((resolveAvailable) => { + const server = createServer() + server.unref() + server.once('error', () => resolveAvailable(false)) + server.listen({ host, port }, () => { + server.close(() => resolveAvailable(true)) + }) + }) +} + +async function resolveAvailablePort(host: string, preferredPort: number): Promise { + for (let offset = 0; offset < 30; offset += 1) { + const port = preferredPort + offset + if (port > 65535) break + if (await isPortAvailable(host, port)) { + return port + } + } + throw new Error(`No available port found from ${preferredPort} to ${preferredPort + 29}`) +} + +function resolveServerCommand(): { command: string; args: string[] } { + const runtimeDir = dirname(fileURLToPath(import.meta.url)) + const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) + + if (!packageRoot) { + throw new Error('Cannot resolve memo package root for core-server launcher') + } + + const distEntry = join(packageRoot, 'dist/core-server.js') + if (existsSync(distEntry)) { + return { + command: process.execPath, + args: [distEntry], + } + } + + const tsEntry = join(packageRoot, 'packages/core/src/server/process_entry.ts') + const tsxCli = join(packageRoot, 'node_modules/tsx/dist/cli.mjs') + + if (existsSync(tsEntry) && existsSync(tsxCli)) { + return { + command: process.execPath, + args: [tsxCli, tsEntry], + } + } + + throw new Error('core-server launcher entry not found (dist/core-server.js missing)') +} + +function registerTestCleanupHook(): void { + if (!process.env.VITEST) return + if (ownsCleanupHook) return + + ownsCleanupHook = true + process.once('exit', () => { + for (const pid of testOwnedPids) { + try { + process.kill(pid, 'SIGTERM') + } catch { + // Ignore already-terminated process. + } + } + }) +} + +async function launchCoreServerProcess(options: { + host: string + port: number + memoHome: string + stateFile: string + password: string + staticDir?: string +}): Promise { + const command = resolveServerCommand() + const args = [ + ...command.args, + '--host', + options.host, + '--port', + String(options.port), + '--memo-home', + options.memoHome, + '--state-file', + options.stateFile, + ] + + if (options.staticDir) { + args.push('--static-dir', options.staticDir) + } + + const child = spawn(command.command, args, { + stdio: 'ignore', + detached: !process.env.VITEST, + env: { + ...process.env, + MEMO_SERVER_PASSWORD: options.password, + }, + }) + + if (typeof child.pid === 'number' && child.pid > 0 && process.env.VITEST) { + registerTestCleanupHook() + testOwnedPids.add(child.pid) + } + + if (!process.env.VITEST) { + child.unref() + } +} + +function toInfo(state: CoreServerState, launched: boolean): CoreServerProcessInfo { + return { + baseUrl: state.baseUrl, + password: state.password, + pid: state.pid, + staticDir: state.staticDir, + launched, + } +} + +async function waitForHealthyState(stateFile: string): Promise { + const startedAt = Date.now() + + while (Date.now() - startedAt < START_TIMEOUT_MS) { + const current = await readServerState(stateFile) + if (current && (await isHealthyState(current))) { + return current + } + await sleep(150) + } + + throw new Error('Timed out waiting for core-server process to become ready') +} + +async function cleanupState( + stateFile: string, + state: CoreServerState | null, + options: { terminateProcess?: boolean } = {}, +): Promise { + if (options.terminateProcess && state && isPidAlive(state.pid)) { + await terminatePid(state.pid) + } + await rm(stateFile, { force: true }) +} + +function needsRestartForStatic( + state: CoreServerState, + staticDir: string | undefined, + requireStaticDir: boolean, +): boolean { + if (!requireStaticDir) return false + if (!staticDir) return false + if (!state.staticDir) return true + return resolve(state.staticDir) !== resolve(staticDir) +} + +export async function ensureCoreServerProcess( + options: EnsureCoreServerProcessOptions = {}, +): Promise { + const host = options.host?.trim() || DEFAULT_HOST + const preferredPort = + typeof options.preferredPort === 'number' && Number.isInteger(options.preferredPort) + ? options.preferredPort + : DEFAULT_PORT + + const memoHome = resolveMemoHome(options.memoHome) + const staticDir = resolveWebStaticDir(options.staticDir) + const requireStaticDir = options.requireStaticDir ?? false + + if (requireStaticDir && !staticDir) { + throw new Error('web-ui static assets not found (index.html missing)') + } + + const paths = resolveServerPaths(memoHome) + await mkdir(paths.runtimeDir, { recursive: true }) + + const existing = await readServerState(paths.stateFile) + if (existing && (await isHealthyState(existing))) { + if (!needsRestartForStatic(existing, staticDir ?? undefined, requireStaticDir)) { + return toInfo(existing, false) + } + } + + const lock = await acquireLock(paths.lockFile) + try { + const current = await readServerState(paths.stateFile) + if (current && (await isHealthyState(current))) { + if (!needsRestartForStatic(current, staticDir ?? undefined, requireStaticDir)) { + return toInfo(current, false) + } + await cleanupState(paths.stateFile, current, { terminateProcess: true }) + } else if (current) { + await cleanupState(paths.stateFile, current) + } + + const port = await resolveAvailablePort(host, preferredPort) + await launchCoreServerProcess({ + host, + port, + memoHome, + stateFile: paths.stateFile, + password: randomUUID(), + staticDir: staticDir ?? undefined, + }) + + const ready = await waitForHealthyState(paths.stateFile) + return toInfo(ready, true) + } catch (error) { + throw new Error(`Failed to ensure core-server process: ${resolveMessage(error)}`) + } finally { + await lock.release() + } +} + +export async function readCoreServerProcessInfo( + memoHomeInput?: string, +): Promise { + const memoHome = resolveMemoHome(memoHomeInput) + const paths = resolveServerPaths(memoHome) + const current = await readServerState(paths.stateFile) + if (!current) return null + if (!(await isHealthyState(current))) return null + return toInfo(current, false) +} + +export async function stopCoreServerProcess(memoHomeInput?: string): Promise { + const memoHome = resolveMemoHome(memoHomeInput) + const paths = resolveServerPaths(memoHome) + const state = await readServerState(paths.stateFile) + if (!state) { + await rm(paths.stateFile, { force: true }) + return false + } + + await cleanupState(paths.stateFile, state) + return true +} diff --git a/packages/tui/src/http/http_agent_session.ts b/packages/tui/src/http/http_agent_session.ts index 88b562a..e6a6512 100644 --- a/packages/tui/src/http/http_agent_session.ts +++ b/packages/tui/src/http/http_agent_session.ts @@ -11,10 +11,11 @@ import type { ToolPermissionMode, TurnResult, TurnStatus, -} from '@memo/core' + LiveSessionState, + SseEventEnvelope, +} from './api_types' import type { ApprovalDecision, ApprovalRequest, RiskLevel } from '@memo/tools/approval' -import type { LiveSessionState, SseEventEnvelope } from '@memo/core' -import { createEmbeddedCoreServerClient, type CoreServerClient } from './core_server_client' +import { getSharedCoreServerClient, type CoreServerClient } from './shared_core_client' type PendingTurn = { input: string @@ -147,7 +148,6 @@ class HttpBackedAgentSession implements HttpAgentSession { constructor( private readonly client: CoreServerClient, private readonly deps: AgentSessionDeps, - private readonly release: () => Promise, initialState: LiveSessionState, ) { this.id = initialState.id @@ -251,7 +251,6 @@ class HttpBackedAgentSession implements HttpAgentSession { } await this.eventsDone - await this.release() })() return this.closePromise @@ -523,24 +522,17 @@ export async function createHttpAgentSession( deps: AgentSessionDeps, options: AgentSessionOptions, ): Promise { - const embedded = await createEmbeddedCoreServerClient({ - memoHome: process.env.MEMO_HOME, + const client = await getSharedCoreServerClient() + + const state = await client.createSession({ + sessionId: options.sessionId, + providerName: options.providerName, + cwd: options.cwd, + toolPermissionMode: normalizeToolPermissionMode(options), + activeMcpServers: options.activeMcpServers, }) - try { - const state = await embedded.client.createSession({ - sessionId: options.sessionId, - providerName: options.providerName, - cwd: options.cwd, - toolPermissionMode: normalizeToolPermissionMode(options), - activeMcpServers: options.activeMcpServers, - }) - - return new HttpBackedAgentSession(embedded.client, deps, embedded.close, state) - } catch (error) { - await embedded.close() - throw new Error(`Failed to create HTTP-backed session: ${resolveErrorMessage(error)}`) - } + return new HttpBackedAgentSession(client, deps, state) } export type { HttpAgentSession } diff --git a/packages/tui/src/http/shared_core_client.ts b/packages/tui/src/http/shared_core_client.ts new file mode 100644 index 0000000..25955c1 --- /dev/null +++ b/packages/tui/src/http/shared_core_client.ts @@ -0,0 +1,50 @@ +import { + createEmbeddedCoreServerClient, + type CoreServerClient, + type ListSessionsQuery, +} from './core_server_client' + +let sharedClientPromise: Promise<{ + client: CoreServerClient + close: () => Promise +}> | null = null + +async function ensureSharedClient() { + if (!sharedClientPromise) { + sharedClientPromise = createEmbeddedCoreServerClient({ + memoHome: process.env.MEMO_HOME, + }).then((embedded) => ({ + client: embedded.client, + close: embedded.close, + })) + } + return sharedClientPromise +} + +export async function getSharedCoreServerClient(): Promise { + const shared = await ensureSharedClient() + return shared.client +} + +export async function withSharedCoreServerClient( + task: (client: CoreServerClient) => Promise, +): Promise { + const client = await getSharedCoreServerClient() + return task(client) +} + +export async function closeSharedCoreServerClient(): Promise { + if (!sharedClientPromise) return + + const pending = sharedClientPromise + sharedClientPromise = null + + try { + const shared = await pending + await shared.close() + } catch { + // Best-effort close. + } +} + +export type { CoreServerClient, ListSessionsQuery } diff --git a/packages/tui/src/mcp.test.ts b/packages/tui/src/mcp.test.ts index 413a894..9af74db 100644 --- a/packages/tui/src/mcp.test.ts +++ b/packages/tui/src/mcp.test.ts @@ -1,18 +1,6 @@ -import { describe, expect, test, vi } from 'vitest' -import { loadMemoConfig, writeMemoConfig } from '@memo/core' +import { describe, expect, test } from 'vitest' import * as mcpModule from './mcp' -vi.mock('@memo/core', () => ({ - loadMemoConfig: vi.fn(), - writeMemoConfig: vi.fn(), -})) - -vi.mock('@memo/tools/router/mcp/oauth', () => ({ - getMcpAuthStatus: vi.fn(), - loginMcpServerOAuth: vi.fn(), - logoutMcpServerOAuth: vi.fn(), -})) - describe('mcp CLI helpers', () => { test('parseEnvAssignment parses valid assignment', () => { const result = mcpModule.parseEnvAssignment('KEY=value') diff --git a/packages/tui/src/mcp.ts b/packages/tui/src/mcp.ts index 05bcb2f..3cb0da7 100644 --- a/packages/tui/src/mcp.ts +++ b/packages/tui/src/mcp.ts @@ -1,12 +1,8 @@ -import { loadMemoConfig, writeMemoConfig, type MCPServerConfig } from '@memo/core' -import { - getMcpAuthStatus, - loginMcpServerOAuth, - logoutMcpServerOAuth, - type McpAuthStatus, -} from '@memo/tools/router/mcp/oauth' +import type { MCPServerConfig } from './http/api_types' +import { withSharedCoreServerClient } from './http/shared_core_client' type McpCommand = 'list' | 'get' | 'add' | 'remove' | 'login' | 'logout' | 'help' +type McpAuthStatus = 'unsupported' | 'not_logged_in' | 'bearer_token' | 'oauth' type AddOptions = { name: string @@ -235,14 +231,6 @@ function parseLoginArgs(rest: string[]): { return { name, scopes } } -function oauthSettingsFromLoaded(loaded: Awaited>) { - return { - memoHome: loaded.home, - storeMode: loaded.config.mcp_oauth_credentials_store_mode, - callbackPort: loaded.config.mcp_oauth_callback_port, - } -} - export async function runMcpCommand(args: string[]): Promise { const { command, rest } = parseCommand(args) @@ -253,49 +241,44 @@ export async function runMcpCommand(args: string[]): Promise { if (command === 'list') { const json = rest.includes('--json') - const loaded = await loadMemoConfig() - const servers = loaded.config.mcp_servers ?? {} - const names = Object.keys(servers) - const settings = oauthSettingsFromLoaded(loaded) - const authStatuses = new Map() - await Promise.all( - names.map(async (name) => { - const config = servers[name] - if (!config) return - try { - const status = await getMcpAuthStatus(config, settings) - authStatuses.set(name, status) - } catch { - authStatuses.set(name, 'unsupported') - } - }), - ) - - if (json) { - const withAuthStatus: Record = - {} - for (const name of names) { - const server = servers[name] - if (!server) continue - withAuthStatus[name] = { - ...(server as MCPServerConfig), - auth_status: authStatuses.get(name) ?? 'unsupported', + try { + const response = await withSharedCoreServerClient((client) => client.listMcpServers()) + const items = [...response.items].sort((left, right) => + left.name.localeCompare(right.name), + ) + + if (json) { + const withAuthStatus: Record< + string, + MCPServerConfig & { auth_status: McpAuthStatus } + > = {} + for (const item of items) { + withAuthStatus[item.name] = { + ...(item.config as MCPServerConfig), + auth_status: item.authStatus, + } } + console.log(JSON.stringify(withAuthStatus, null, 2)) + return + } + + if (items.length === 0) { + console.log('No MCP servers configured. Add one with "memo mcp add".') + return + } + + console.log(`MCP servers (${items.length}):`) + for (const item of items) { + console.log( + formatServer(item.name, item.config as MCPServerConfig, item.authStatus), + ) } - console.log(JSON.stringify(withAuthStatus, null, 2)) return - } - if (names.length === 0) { - console.log(`No MCP servers configured. Add one with "memo mcp add".`) + } catch (error) { + console.error(getErrorMessage(error)) + process.exitCode = 1 return } - console.log(`MCP servers (${names.length}):`) - for (const name of names) { - const config = servers[name] - if (!config) continue - console.log(formatServer(name, config, authStatuses.get(name) ?? 'unsupported')) - } - return } if (command === 'get') { @@ -306,19 +289,20 @@ export async function runMcpCommand(args: string[]): Promise { process.exitCode = 1 return } - const loaded = await loadMemoConfig() - const server = loaded.config.mcp_servers?.[name] - if (!server) { - console.error(`Unknown MCP server "${name}".`) - process.exitCode = 1 + + try { + const item = await withSharedCoreServerClient((client) => client.getMcpServer(name)) + if (json) { + console.log(JSON.stringify(item.config, null, 2)) + return + } + console.log(formatServer(item.name, item.config as MCPServerConfig, item.authStatus)) return - } - if (json) { - console.log(JSON.stringify(server, null, 2)) + } catch (error) { + console.error(getErrorMessage(error)) + process.exitCode = 1 return } - console.log(formatServer(name, server)) - return } if (command === 'add') { @@ -333,6 +317,7 @@ export async function runMcpCommand(args: string[]): Promise { } const options = parsed.options if (!options) return + if (options.url) { try { new URL(options.url) @@ -343,17 +328,9 @@ export async function runMcpCommand(args: string[]): Promise { } } - const loaded = await loadMemoConfig() - const servers = { ...(loaded.config.mcp_servers ?? {}) } - if (servers[options.name]) { - console.error(`MCP server "${options.name}" already exists.`) - process.exitCode = 1 - return - } - - let entry: MCPServerConfig + let config: MCPServerConfig if (options.url) { - entry = { + config = { type: 'streamable_http', url: options.url, ...(options.bearerTokenEnvVar @@ -361,20 +338,24 @@ export async function runMcpCommand(args: string[]): Promise { : {}), } } else { - entry = { + config = { command: options.command!, args: options.args && options.args.length > 0 ? options.args : undefined, env: options.env, } } - servers[options.name] = entry - await writeMemoConfig(loaded.configPath, { - ...loaded.config, - mcp_servers: servers, - }) - console.log(`Added MCP server "${options.name}".`) - return + try { + await withSharedCoreServerClient((client) => + client.createMcpServer(options.name, config), + ) + console.log(`Added MCP server "${options.name}".`) + return + } catch (error) { + console.error(getErrorMessage(error)) + process.exitCode = 1 + return + } } if (command === 'remove') { @@ -384,20 +365,16 @@ export async function runMcpCommand(args: string[]): Promise { process.exitCode = 1 return } - const loaded = await loadMemoConfig() - const servers = { ...(loaded.config.mcp_servers ?? {}) } - if (!servers[name]) { - console.error(`Unknown MCP server "${name}".`) + + try { + await withSharedCoreServerClient((client) => client.removeMcpServer(name)) + console.log(`Removed MCP server "${name}".`) + return + } catch (error) { + console.error(getErrorMessage(error)) process.exitCode = 1 return } - delete servers[name] - await writeMemoConfig(loaded.configPath, { - ...loaded.config, - mcp_servers: servers, - }) - console.log(`Removed MCP server "${name}".`) - return } if (command === 'login') { @@ -417,41 +394,16 @@ export async function runMcpCommand(args: string[]): Promise { process.exitCode = 1 return } - const loaded = await loadMemoConfig() - const server = loaded.config.mcp_servers?.[name] - if (!server) { - console.error(`Unknown MCP server "${name}".`) - process.exitCode = 1 - return - } - if (!('url' in server)) { - console.error('OAuth login only applies to streamable HTTP servers.') - process.exitCode = 1 - return - } - console.log(`Starting OAuth login for "${name}"...`) try { - const result = await loginMcpServerOAuth({ - serverName: name, - config: server, - scopes: parsed.scopes, - settings: oauthSettingsFromLoaded(loaded), - onAuthorizationUrl: (url) => { - console.log(`Open this URL to authorize:\n${url}`) - }, - onBrowserOpenFailure: () => { - console.log('Browser launch failed. Open the URL above manually.') - }, - }) - console.log( - `OAuth login completed for "${name}" (credentials stored in ${result.backend}).`, - ) + await withSharedCoreServerClient((client) => client.loginMcpServer(name, parsed.scopes)) + console.log(`OAuth login completed for "${name}".`) + return } catch (error) { console.error(getErrorMessage(error)) process.exitCode = 1 + return } - return } if (command === 'logout') { @@ -461,34 +413,16 @@ export async function runMcpCommand(args: string[]): Promise { process.exitCode = 1 return } - const loaded = await loadMemoConfig() - const server = loaded.config.mcp_servers?.[name] - if (!server) { - console.error(`Unknown MCP server "${name}".`) - process.exitCode = 1 - return - } - if (!('url' in server)) { - console.error('OAuth logout only applies to streamable HTTP servers.') - process.exitCode = 1 - return - } try { - const result = await logoutMcpServerOAuth({ - config: server, - settings: oauthSettingsFromLoaded(loaded), - }) - if (result.removed) { - console.log(`Removed OAuth credentials for "${name}".`) - } else { - console.log(`No OAuth credentials stored for "${name}".`) - } + await withSharedCoreServerClient((client) => client.logoutMcpServer(name)) + console.log(`Removed OAuth credentials for "${name}".`) + return } catch (error) { console.error(getErrorMessage(error)) process.exitCode = 1 + return } - return } console.error(`Unknown subcommand: ${command}`) diff --git a/packages/tui/src/review/backend.test.ts b/packages/tui/src/review/backend.test.ts index 1e4fffc..e9a69cf 100644 --- a/packages/tui/src/review/backend.test.ts +++ b/packages/tui/src/review/backend.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert' import { describe, test } from 'vitest' -import type { MCPServerConfig } from '@memo/core' +import type { MCPServerConfig } from '../http/api_types' import { detectGitHubMcpToolPrefixes, findActiveGitHubMcpServer, diff --git a/packages/tui/src/review/backend.ts b/packages/tui/src/review/backend.ts index 243730c..cd718cd 100644 --- a/packages/tui/src/review/backend.ts +++ b/packages/tui/src/review/backend.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import type { MCPServerConfig } from '@memo/core' +import type { MCPServerConfig } from '../http/api_types' const GH_COMMAND_TIMEOUT_MS = 12_000 const GH_REVIEW_PERMISSIONS = new Set(['WRITE', 'MAINTAIN', 'ADMIN']) diff --git a/packages/tui/src/setup/SetupWizard.tsx b/packages/tui/src/setup/SetupWizard.tsx index 5af764b..28b5e69 100644 --- a/packages/tui/src/setup/SetupWizard.tsx +++ b/packages/tui/src/setup/SetupWizard.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput } from 'ink' import { Spinner, StatusMessage, TextInput } from '@inkjs/ui' import { memo, useCallback, useMemo, useState } from 'react' -import { writeMemoConfig, type MemoConfig } from '@memo/core' +import { withSharedCoreServerClient } from '../http/shared_core_client' type SetupWizardProps = { configPath: string @@ -65,18 +65,19 @@ export const SetupWizard = memo(function SetupWizard({ setBusy(true) setError(null) try { - const config: MemoConfig = { - current_provider: nextValues.name, - providers: [ - { - name: nextValues.name, - env_api_key: nextValues.envKey, - model: nextValues.model, - base_url: nextValues.baseUrl || undefined, - }, - ], - } - await writeMemoConfig(configPath, config) + await withSharedCoreServerClient((client) => + client.patchConfig({ + current_provider: nextValues.name, + providers: [ + { + name: nextValues.name, + env_api_key: nextValues.envKey, + model: nextValues.model, + base_url: nextValues.baseUrl || undefined, + }, + ], + }), + ) onComplete() } catch (err) { setError((err as Error).message) diff --git a/packages/tui/src/slash/registry.ts b/packages/tui/src/slash/registry.ts index 1fbbd80..e52f0a3 100644 --- a/packages/tui/src/slash/registry.ts +++ b/packages/tui/src/slash/registry.ts @@ -1 +1,254 @@ -export { SLASH_SPECS, buildHelpText, resolveSlashCommand } from '@memo/core/runtime/slash' +import { formatSlashCommand, SLASH_COMMANDS } from '../constants' +import { + TOOL_PERMISSION_MODES, + type SlashCommandResult, + type SlashContext, + type SlashSpec, +} from './types' +import type { ToolPermissionMode } from './types' + +export const SLASH_SPECS: SlashSpec[] = [ + { name: SLASH_COMMANDS.HELP, description: 'Show command and shortcut help' }, + { name: SLASH_COMMANDS.EXIT, description: 'Exit current session' }, + { name: SLASH_COMMANDS.NEW, description: 'Start a fresh session' }, + { name: SLASH_COMMANDS.RESUME, description: 'List and load session history' }, + { name: SLASH_COMMANDS.REVIEW, description: 'Review a GitHub pull request and post comments' }, + { name: SLASH_COMMANDS.MODELS, description: 'List or switch configured models' }, + { + name: SLASH_COMMANDS.TOOLS, + description: 'Set tool permission mode (none/once/full)', + }, + { name: SLASH_COMMANDS.COMPACT, description: 'Compact conversation context now' }, + { name: SLASH_COMMANDS.MCP, description: 'Show configured MCP servers' }, + { name: SLASH_COMMANDS.INIT, description: 'Generate AGENTS.md with agent instructions' }, +] + +const TOOL_PERMISSION_MODE_ALIASES: Record = { + none: TOOL_PERMISSION_MODES.NONE, + off: TOOL_PERMISSION_MODES.NONE, + disabled: TOOL_PERMISSION_MODES.NONE, + 'no-tools': TOOL_PERMISSION_MODES.NONE, + once: TOOL_PERMISSION_MODES.ONCE, + ask: TOOL_PERMISSION_MODES.ONCE, + single: TOOL_PERMISSION_MODES.ONCE, + strict: TOOL_PERMISSION_MODES.ONCE, + full: TOOL_PERMISSION_MODES.FULL, + all: TOOL_PERMISSION_MODES.FULL, + dangerous: TOOL_PERMISSION_MODES.FULL, + 'full-access': TOOL_PERMISSION_MODES.FULL, +} + +function parseToolPermissionMode(input: string | undefined): ToolPermissionMode | null { + if (!input) return null + const normalized = input.trim().toLowerCase() + if (!normalized) return null + return TOOL_PERMISSION_MODE_ALIASES[normalized] ?? null +} + +function toolPermissionLabel(mode: ToolPermissionMode): string { + if (mode === TOOL_PERMISSION_MODES.NONE) return 'none (no tools)' + if (mode === TOOL_PERMISSION_MODES.ONCE) return 'once (approval required)' + return 'full (no approval)' +} + +export function buildHelpText(): string { + const maxName = SLASH_SPECS.reduce((max, item) => Math.max(max, item.name.length), 0) + const commandLines = SLASH_SPECS.map( + (item) => ` ${formatSlashCommand(item.name).padEnd(maxName + 3)} ${item.description}`, + ) + + return [ + 'Available commands:', + ...commandLines, + ' exit Exit session (without slash)', + '', + 'Shortcuts:', + ' Enter Send message', + ' Shift+Enter New line', + ' Up/Down Browse local input history', + ' Tab Accept active suggestion', + ' Ctrl+L Clear screen and start new session', + ' Esc Esc Interrupt running turn / clear input', + ].join('\n') +} + +function parseReviewPrNumber(input: string | undefined): number | null { + if (!input) return null + const normalized = input.trim() + if (!normalized) return null + + const directMatch = normalized.match(/^#?(\d+)$/) + if (directMatch) { + const parsed = Number(directMatch[1]) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null + } + + const urlMatch = normalized.match(/\/pull\/(\d+)(?:[/?#].*)?$/i) + if (urlMatch) { + const parsed = Number(urlMatch[1]) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null + } + + return null +} + +export function resolveSlashCommand(raw: string, context: SlashContext): SlashCommandResult { + const [commandRaw, ...rest] = raw.trim().slice(1).split(/\s+/) + const command = (commandRaw ?? '').toLowerCase() + + switch (command) { + case SLASH_COMMANDS.HELP: + return { kind: 'message', title: 'Help', content: buildHelpText() } + + case SLASH_COMMANDS.EXIT: + return { kind: 'exit' } + + case SLASH_COMMANDS.NEW: + return { kind: 'new' } + + case SLASH_COMMANDS.RESUME: + return { + kind: 'message', + title: 'Resume', + content: 'Type "resume" followed by keywords to load local session history.', + } + + case SLASH_COMMANDS.REVIEW: { + const arg = rest.join(' ').trim() + const prNumber = parseReviewPrNumber(arg) + if (!prNumber) { + return { + kind: 'message', + title: 'Review', + content: `Usage: ${formatSlashCommand(SLASH_COMMANDS.REVIEW)} \nExamples: /review 999, /review #999`, + } + } + return { + kind: 'review_pr', + prNumber, + } + } + + case SLASH_COMMANDS.MODELS: { + if (!context.providers.length) { + return { + kind: 'message', + title: 'Models', + content: `No providers configured. Check ${context.configPath}`, + } + } + + const query = rest.join(' ').trim() + const found = + context.providers.find((provider) => provider.name === query) ?? + context.providers.find((provider) => provider.model === query) + + if (found) { + return { kind: 'switch_model', provider: found } + } + + const lines = context.providers.map((provider) => { + const marker = + provider.name === context.providerName && provider.model === context.model + ? ' (current)' + : '' + const base = provider.base_url ? ` @ ${provider.base_url}` : '' + return `- ${provider.name}: ${provider.model}${base}${marker}` + }) + + const prefix = query ? `Not found: ${query}\n\n` : '' + return { + kind: 'message', + title: 'Models', + content: `${prefix}${lines.join('\n')}`, + } + } + + case SLASH_COMMANDS.TOOLS: { + const rawMode = rest.join(' ').trim() + const parsedMode = parseToolPermissionMode(rawMode) + const options = ['none', 'once', 'full'].join(', ') + + if (!rawMode) { + return { + kind: 'message', + title: 'Tools', + content: `Current: ${toolPermissionLabel(context.toolPermissionMode)}\nUsage: ${formatSlashCommand(SLASH_COMMANDS.TOOLS)} \nModes: ${options}`, + } + } + + if (!parsedMode) { + return { + kind: 'message', + title: 'Tools', + content: `Unsupported mode: ${rawMode}\nChoose one of: ${options}`, + } + } + + if (parsedMode === context.toolPermissionMode) { + return { + kind: 'message', + title: 'Tools', + content: `Already using ${toolPermissionLabel(parsedMode)}.`, + } + } + + return { + kind: 'set_tool_permission', + mode: parsedMode, + } + } + + case SLASH_COMMANDS.COMPACT: + return { kind: 'compact' } + + case SLASH_COMMANDS.MCP: { + const names = Object.keys(context.mcpServers) + if (!names.length) { + return { + kind: 'message', + title: 'MCP Servers', + content: 'No MCP servers configured in current config.', + } + } + + const lines: string[] = [] + lines.push(`Total: ${names.length}`) + lines.push('') + + for (const [name, server] of Object.entries(context.mcpServers)) { + lines.push(`- ${name}`) + if ('url' in server) { + lines.push(` type: ${server.type ?? 'streamable_http'}`) + lines.push(` url: ${server.url}`) + if (server.bearer_token_env_var) { + lines.push(` bearer: ${server.bearer_token_env_var}`) + } + } else { + lines.push(` type: ${server.type ?? 'stdio'}`) + lines.push(` command: ${server.command}`) + if (server.args?.length) { + lines.push(` args: ${server.args.join(' ')}`) + } + } + lines.push('') + } + + return { + kind: 'message', + title: 'MCP Servers', + content: lines.join('\n'), + } + } + + case SLASH_COMMANDS.INIT: + return { kind: 'init_agents_md' } + + default: + return { + kind: 'message', + title: 'Unknown', + content: `Unknown command: ${raw}\nType ${formatSlashCommand(SLASH_COMMANDS.HELP)} for available commands.`, + } + } +} diff --git a/packages/tui/src/slash/types.ts b/packages/tui/src/slash/types.ts index 64c6305..597ab49 100644 --- a/packages/tui/src/slash/types.ts +++ b/packages/tui/src/slash/types.ts @@ -1,7 +1,46 @@ -export type { - SlashContext, - SlashCommandResult, - SlashSpec, - SlashCommandName, -} from '@memo/core/runtime/slash' -export type { ToolPermissionMode } from '@memo/core/types' +import { TOOL_PERMISSION_MODES, type ToolPermissionMode } from '../constants' + +export { TOOL_PERMISSION_MODES } +export type { ToolPermissionMode } + +export type SlashProvider = { + name: string + model: string + base_url?: string +} + +export type McpServerConfig = + | { + type?: 'streamable_http' + url: string + bearer_token_env_var?: string + } + | { + type?: 'stdio' + command: string + args?: string[] + } + +export type SlashContext = { + configPath: string + providerName: string + model: string + mcpServers: Record + providers: SlashProvider[] + toolPermissionMode: ToolPermissionMode +} + +export type SlashCommandResult = + | { kind: 'exit' } + | { kind: 'new' } + | { kind: 'message'; title: string; content: string } + | { kind: 'review_pr'; prNumber: number } + | { kind: 'switch_model'; provider: SlashProvider } + | { kind: 'set_tool_permission'; mode: ToolPermissionMode } + | { kind: 'compact' } + | { kind: 'init_agents_md' } + +export type SlashSpec = { + name: string + description: string +} diff --git a/packages/tui/src/state/chat_timeline.ts b/packages/tui/src/state/chat_timeline.ts index 2ad6af8..db5c63e 100644 --- a/packages/tui/src/state/chat_timeline.ts +++ b/packages/tui/src/state/chat_timeline.ts @@ -1,4 +1,4 @@ -import type { ContextUsagePhase, TokenUsage, TurnStatus } from '@memo/core' +import type { ContextUsagePhase, TokenUsage, TurnStatus } from '../http/api_types' import type { StepView, SystemMessage, diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 48fc38a..1e95432 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -1,4 +1,4 @@ -import type { TokenUsage, TurnStatus } from '@memo/core' +import type { TokenUsage, TurnStatus } from './http/api_types' export const TOOL_STATUS = { PENDING: 'pending', diff --git a/packages/tui/src/web/run_web_command.test.ts b/packages/tui/src/web/run_web_command.test.ts index 2e9a1d4..14adf96 100644 --- a/packages/tui/src/web/run_web_command.test.ts +++ b/packages/tui/src/web/run_web_command.test.ts @@ -1,67 +1,41 @@ import { mkdtemp, writeFile } from 'node:fs/promises' -import { createServer } from 'node:net' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -const { startCoreHttpServerMock } = vi.hoisted(() => ({ - startCoreHttpServerMock: vi.fn(), +const { createEmbeddedCoreServerClientMock } = vi.hoisted(() => ({ + createEmbeddedCoreServerClientMock: vi.fn(), })) -vi.mock('@memo/core', () => ({ - startCoreHttpServer: startCoreHttpServerMock, +vi.mock('../http/core_server_client', () => ({ + createEmbeddedCoreServerClient: createEmbeddedCoreServerClientMock, })) import { runWebCommand } from './run_web_command' -async function reserveAvailablePort(): Promise { - return new Promise((resolvePort, rejectPort) => { - const server = createServer() - server.once('error', rejectPort) - server.listen({ host: '127.0.0.1', port: 0 }, () => { - const address = server.address() - if (!address || typeof address === 'string') { - server.close(() => rejectPort(new Error('failed to reserve port'))) - return - } - const port = address.port - server.close((error) => { - if (error) { - rejectPort(error) - return - } - resolvePort(port) - }) - }) - }) -} - describe('runWebCommand', () => { - const originalPassword = process.env.MEMO_SERVER_PASSWORD - beforeEach(() => { - startCoreHttpServerMock.mockReset() + createEmbeddedCoreServerClientMock.mockReset() }) afterEach(() => { - process.env.MEMO_SERVER_PASSWORD = originalPassword vi.restoreAllMocks() }) - test('starts core server and closes on SIGTERM', async () => { + test('ensures core server and exits on SIGTERM', async () => { const staticDir = await mkdtemp(join(tmpdir(), 'memo-web-static-')) await writeFile(join(staticDir, 'index.html'), '', 'utf8') - const preferredPort = await reserveAvailablePort() - const closeMock = vi.fn(async () => {}) - startCoreHttpServerMock.mockResolvedValue({ - url: `http://127.0.0.1:${preferredPort}`, - openApiSpecPath: '/api/openapi.json', - close: closeMock, + createEmbeddedCoreServerClientMock.mockResolvedValue({ + client: {}, + server: { + url: 'http://127.0.0.1:5494', + openApiSpecPath: '/api/openapi.json', + close: vi.fn(async () => {}), + }, + close: vi.fn(async () => {}), }) - process.env.MEMO_SERVER_PASSWORD = 'test-password' - const signalHandlers = new Map void>() const onceSpy = vi.spyOn(process, 'once').mockImplementation(((event, listener) => { if ((event === 'SIGINT' || event === 'SIGTERM') && typeof listener === 'function') { @@ -74,14 +48,14 @@ describe('runWebCommand', () => { '--host', '127.0.0.1', '--port', - String(preferredPort), + '5494', '--static-dir', staticDir, '--no-open', ]) await vi.waitFor(() => { - expect(startCoreHttpServerMock).toHaveBeenCalledTimes(1) + expect(createEmbeddedCoreServerClientMock).toHaveBeenCalledTimes(1) }) const terminate = signalHandlers.get('SIGTERM') @@ -90,13 +64,13 @@ describe('runWebCommand', () => { await commandPromise - expect(startCoreHttpServerMock).toHaveBeenCalledWith({ + expect(createEmbeddedCoreServerClientMock).toHaveBeenCalledWith({ host: '127.0.0.1', - port: preferredPort, - password: 'test-password', + preferredPort: 5494, + memoHome: process.env.MEMO_HOME, staticDir, + requireStaticDir: true, }) - expect(closeMock).toHaveBeenCalledTimes(1) onceSpy.mockRestore() }) }) diff --git a/packages/tui/src/web/run_web_command.ts b/packages/tui/src/web/run_web_command.ts index b146843..07fd60e 100644 --- a/packages/tui/src/web/run_web_command.ts +++ b/packages/tui/src/web/run_web_command.ts @@ -1,10 +1,7 @@ import { spawn } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { createServer } from 'node:net' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { startCoreHttpServer } from '@memo/core' import { parseWebArgs } from './cli_web_args' +import { createEmbeddedCoreServerClient } from '../http/core_server_client' +import { resolveWebStaticDir } from '../http/core_server_process' const DEFAULT_HOST = '127.0.0.1' const DEFAULT_PORT = 5494 @@ -14,72 +11,6 @@ type BrowserCommand = { args: string[] } -function findMemoPackageRoot(startDir: string): string | null { - let dir = resolve(startDir) - while (true) { - const pkgPath = join(dir, 'package.json') - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string } - if (pkg.name === '@memo-code/memo') return dir - } catch { - // ignore and keep walking - } - } - const parent = dirname(dir) - if (parent === dir) break - dir = parent - } - return null -} - -function hasFile(path: string): boolean { - return existsSync(path) -} - -function resolveWebStaticDir(explicitPath?: string): string | null { - const candidates: string[] = [] - if (explicitPath) candidates.push(resolve(explicitPath)) - if (process.env.MEMO_WEB_STATIC_DIR) { - candidates.push(resolve(process.env.MEMO_WEB_STATIC_DIR)) - } - - const runtimeDir = dirname(fileURLToPath(import.meta.url)) - const packageRoot = findMemoPackageRoot(runtimeDir) ?? findMemoPackageRoot(process.cwd()) - if (packageRoot) { - candidates.push(join(packageRoot, 'dist/web/ui')) - candidates.push(join(packageRoot, 'packages/web-ui/dist')) - } - - candidates.push(join(process.cwd(), 'dist/web/ui')) - candidates.push(join(process.cwd(), 'packages/web-ui/dist')) - - for (const candidate of candidates) { - if (hasFile(join(candidate, 'index.html'))) return candidate - } - return null -} - -async function isPortAvailable(host: string, port: number): Promise { - return new Promise((resolveAvailable) => { - const server = createServer() - server.unref() - server.once('error', () => resolveAvailable(false)) - server.listen({ host, port }, () => { - server.close(() => resolveAvailable(true)) - }) - }) -} - -async function resolveAvailablePort(host: string, preferredPort: number): Promise { - for (let offset = 0; offset < 30; offset++) { - const port = preferredPort + offset - if (port > 65535) break - if (await isPortAvailable(host, port)) return port - } - throw new Error(`No available port found from ${preferredPort} to ${preferredPort + 29}`) -} - function buildBrowserCommand(url: string, platform: NodeJS.Platform): BrowserCommand | null { if (platform === 'darwin') { return { command: 'open', args: [url] } @@ -108,16 +39,10 @@ function openBrowser(url: string): boolean { } } -function formatAddress(host: string, port: number): string { - const safeHost = host.includes(':') ? `[${host}]` : host - return `http://${safeHost}:${port}` -} - export async function runWebCommand(argv: string[]): Promise { const options = parseWebArgs(argv) const host = options.host ?? DEFAULT_HOST const preferredPort = options.port ?? DEFAULT_PORT - const port = await resolveAvailablePort(host, preferredPort) const staticDir = resolveWebStaticDir(options.staticDir) if (!staticDir) { @@ -127,61 +52,36 @@ export async function runWebCommand(argv: string[]): Promise { return } - const password = process.env.MEMO_SERVER_PASSWORD?.trim() - if (!password) { - console.error('MEMO_SERVER_PASSWORD is required for `memo web`.') - console.error('Example: MEMO_SERVER_PASSWORD=your-password memo web') - process.exitCode = 1 - return - } - - if (port !== preferredPort) { - console.log(`[memo web] Port ${preferredPort} is busy, using ${port}`) - } - - let handle: Awaited> | null = null + let clientHandle: Awaited> | null = null try { - handle = await startCoreHttpServer({ + clientHandle = await createEmbeddedCoreServerClient({ host, - port, - password, + preferredPort, + memoHome: process.env.MEMO_HOME, staticDir, + requireStaticDir: true, }) } catch (error) { - console.error(`Failed to start core server: ${(error as Error).message}`) + console.error(`Failed to ensure core server: ${(error as Error).message}`) process.exitCode = 1 return } - const url = handle.url || formatAddress(host, port) + const url = clientHandle.server.url console.log(`[memo web] Server: ${url}`) console.log(`[memo web] Static: ${staticDir}`) - console.log(`[memo web] OpenAPI: ${url}${handle.openApiSpecPath}`) + console.log(`[memo web] OpenAPI: ${url}${clientHandle.server.openApiSpecPath}`) if (options.open && !openBrowser(url)) { console.warn(`[memo web] Failed to auto-open browser. Open manually: ${url}`) } - const shutdown = async () => { - if (!handle) return - const toClose = handle - handle = null - await toClose.close() - } - await new Promise((resolveDone) => { - const onSignal = (signal: NodeJS.Signals) => { - void shutdown() - .catch((error) => { - console.error( - `Failed to stop core server on ${signal}: ${(error as Error).message}`, - ) - process.exitCode = 1 - }) - .finally(() => resolveDone()) + const onSignal = () => { + resolveDone() } - process.once('SIGINT', () => onSignal('SIGINT')) - process.once('SIGTERM', () => onSignal('SIGTERM')) + process.once('SIGINT', onSignal) + process.once('SIGTERM', onSignal) }) } diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..534b3d0 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,8 @@ +{ + "name": "@memo-code/types", + "type": "module", + "module": "src/index.ts", + "types": "src/index.ts", + "version": "0.1.0", + "private": true +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..64ab877 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,422 @@ +export type ProviderConfig = { + name: string + env_api_key: string + model: string + base_url?: string +} + +export type ModelProfileOverride = { + supports_parallel_tool_calls?: boolean + supports_reasoning_content?: boolean + context_window?: number +} + +export type MCPServerConfig = + | { + type?: 'stdio' + command: string + args?: string[] + env?: Record + stderr?: 'inherit' | 'pipe' | 'ignore' + } + | { + type?: 'streamable_http' + url: string + headers?: Record + http_headers?: Record + bearer_token_env_var?: string + } + +export type MemoConfig = { + current_provider: string + model_profiles?: Record + mcp_servers?: Record + active_mcp_servers?: string[] + active_skills?: string[] + mcp_oauth_credentials_store_mode?: 'auto' | 'keyring' | 'file' + mcp_oauth_callback_port?: number + auto_compact_threshold_percent?: number + providers: ProviderConfig[] +} + +export type ApiSuccessMeta = { + requestId: string + timestamp: string +} + +export type ApiErrorInfo = { + code: string + message: string + details?: unknown +} + +export type ApiErrorMeta = ApiSuccessMeta & { + path?: string +} + +export type OpenApiError = ApiErrorInfo + +export type ApiEnvelope = + | { + success: true + data: T + meta: ApiSuccessMeta + } + | { + success: false + error: ApiErrorInfo + meta: ApiErrorMeta + } + +export type AuthLoginRequest = { + password: string +} + +export type AuthLoginResponse = { + accessToken: string + expiresIn: number +} + +export type SseEventEnvelope = { + event: string + data: unknown + seq: number + ts: string +} + +export type TokenUsageSummary = { + prompt: number + completion: number + total: number +} + +export type ToolUsageSummary = { + total: number + success: number + failed: number + denied: number + cancelled: number +} + +export type SessionRuntimeStatus = 'idle' | 'running' | 'error' | 'cancelled' + +export type SessionDateInfo = { + day: string + startedAt: string + updatedAt: string +} + +export type SessionListItem = { + id: string + sessionId: string + filePath: string + title: string + project: string + workspaceId: string + cwd: string + date: SessionDateInfo + status: SessionRuntimeStatus + turnCount: number + tokenUsage: TokenUsageSummary + toolUsage: ToolUsageSummary +} + +export type SessionEventItem = { + index: number + ts: string + type: string + turn?: number + step?: number + role?: string + content?: string + meta?: Record +} + +export type SessionTurnStep = { + step: number + assistantText?: string + thinking?: string + action?: { + tool: string + input: unknown + } + parallelActions?: Array<{ + tool: string + input: unknown + }> + observation?: string + resultStatus?: string +} + +export type SessionTurnDetail = { + turn: number + input?: string + startedAt?: string + finalText?: string + status?: string + errorMessage?: string + tokenUsage?: TokenUsageSummary + steps: SessionTurnStep[] +} + +export type SessionDetail = SessionListItem & { + summary: string + turns: SessionTurnDetail[] + events: SessionEventItem[] +} + +export type SessionListResponse = { + items: SessionListItem[] + page: number + pageSize: number + total: number + totalPages: number +} + +export type SessionEventsResponse = { + items: SessionEventItem[] + nextCursor: string | null +} + +export type ToolPermissionMode = 'none' | 'once' | 'full' + +export type AssistantToolCall = { + id: string + type: 'function' + function: { + name: string + arguments: string + } +} + +export type ChatMessage = + | { role: 'system'; content: string } + | { role: 'user'; content: string } + | { + role: 'assistant' + content: string + reasoning_content?: string + tool_calls?: AssistantToolCall[] + } + | { role: 'tool'; content: string; tool_call_id: string; name?: string } + +export type QueuedInputItem = { + id: string + input: string + createdAt: string +} + +export type LiveSessionState = { + id: string + title: string + workspaceId: string + projectName: string + providerName: string + model: string + cwd: string + startedAt: string + status: 'idle' | 'running' | 'closed' + pendingApproval?: { + fingerprint: string + toolName: string + reason: string + riskLevel: string + params: unknown + } + activeMcpServers: string[] + toolPermissionMode: ToolPermissionMode + queuedInputs: QueuedInputItem[] + currentContextTokens?: number + contextWindow?: number + historyFilePath?: string + availableToolNames?: string[] +} + +export type WsServerEvent = + | { type: 'session.snapshot'; payload: LiveSessionState } + | { + type: 'turn.start' + payload: { turn: number; input: string; promptTokens?: number } + } + | { + type: 'assistant.chunk' + payload: { turn: number; step: number; chunk: string } + } + | { + type: 'context.usage' + payload: { + turn: number + step: number + phase: 'turn_start' | 'step_start' | 'post_compact' + promptTokens: number + contextWindow: number + thresholdTokens: number + usagePercent: number + } + } + | { + type: 'context.compact' + payload: { + turn: number + step: number + reason: 'auto' | 'manual' + status: 'success' | 'failed' | 'skipped' + beforeTokens: number + afterTokens: number + thresholdTokens: number + reductionPercent: number + summary?: string + errorMessage?: string + } + } + | { + type: 'tool.action' + payload: { + turn: number + step: number + action: { tool: string; input: unknown } + parallelActions?: Array<{ tool: string; input: unknown }> + thinking?: string + } + } + | { + type: 'tool.observation' + payload: { + turn: number + step: number + observation: string + resultStatus?: string + parallelResultStatuses?: string[] + } + } + | { + type: 'turn.final' + payload: { + turn: number + step?: number + finalText: string + status: string + errorMessage?: string + turnUsage?: TokenUsageSummary + tokenUsage?: TokenUsageSummary + } + } + | { + type: 'approval.request' + payload: { + fingerprint: string + toolName: string + reason: string + riskLevel: string + params: unknown + } + } + | { + type: 'session.status' + payload: { + status: 'idle' | 'running' | 'closed' + } + } + | { + type: 'system.message' + payload: { + title: string + content: string + tone?: 'info' | 'warning' | 'error' + } + } + | { + type: 'error' + payload: { + code: string + message: string + } + } + +export type SkillRecord = { + id: string + name: string + description: string + scope: 'project' | 'global' + path: string + active: boolean +} + +export type McpServerRecord = { + name: string + config: Record + authStatus: 'unsupported' | 'not_logged_in' | 'bearer_token' | 'oauth' + active: boolean +} + +export type WorkspaceRecord = { + id: string + name: string + cwd: string + createdAt: string + lastUsedAt: string +} + +export type WorkspaceDirEntry = { + name: string + path: string + kind: 'dir' + readable: boolean +} + +export type WorkspaceFsListResult = { + path: string + parentPath: string | null + items: WorkspaceDirEntry[] +} + +export type SessionRuntimeBadge = { + sessionId: string + status: 'idle' | 'running' | 'closed' + workspaceId: string + updatedAt: string +} + +export type FileSuggestion = { + id: string + path: string + name: string + parent?: string + isDir: boolean +} + +export type FileSuggestionRequest = { + cwd: string + query: string + limit?: number + maxDepth?: number + maxEntries?: number + respectGitIgnore?: boolean + ignoreGlobs?: string[] +} + +export type ConfigSnapshot = { + configPath: string + memoHome: string + needsSetup: boolean + currentProvider: string + selectedProvider: { + name: string + model: string + contextWindow: number + } + providers: ProviderConfig[] + modelProfiles?: Record + mcpServers: Record + activeMcpServers: string[] + autoCompactThresholdPercent: number +} + +export type UpdateConfigRequest = { + current_provider?: string + providers?: ProviderConfig[] + model_profiles?: Record + mcp_servers?: Record + active_mcp_servers?: string[] + auto_compact_threshold_percent?: number +} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 4cad107..a46ec80 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -4,15 +4,13 @@ "version": "0.0.0", "type": "module", "scripts": { - "predev": "pnpm --filter @memo-code/core build", - "prebuild": "pnpm --filter @memo-code/core build", "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { - "@memo-code/core": "workspace:*", + "@memo-code/types": "workspace:*", "@streamdown/cjk": "^1.0.2", "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", diff --git a/packages/web-ui/src/api/types.ts b/packages/web-ui/src/api/types.ts index 11a3268..5e7690a 100644 --- a/packages/web-ui/src/api/types.ts +++ b/packages/web-ui/src/api/types.ts @@ -1,7 +1,6 @@ import type { ApiEnvelope as CoreApiEnvelope, SseEventEnvelope as CoreSseEventEnvelope, - FileSuggestion as CoreFileSuggestion, LiveSessionState as CoreLiveSessionState, McpServerRecord as CoreMcpServerRecord, QueuedInputItem as CoreQueuedInputItem, @@ -21,7 +20,8 @@ import type { WorkspaceDirEntry as CoreWorkspaceDirEntry, WorkspaceFsListResult as CoreWorkspaceFsListResult, WorkspaceRecord as CoreWorkspaceRecord, -} from '@memo-code/core' + FileSuggestion as CoreFileSuggestion, +} from '@memo-code/types' export type ApiMeta = { requestId: string diff --git a/packages/web-ui/tsconfig.app.json b/packages/web-ui/tsconfig.app.json index 03adc7d..9eea055 100644 --- a/packages/web-ui/tsconfig.app.json +++ b/packages/web-ui/tsconfig.app.json @@ -26,7 +26,8 @@ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@memo-code/types": ["../types/src/index.ts"] } }, "include": ["src"] diff --git a/packages/web-ui/tsconfig.json b/packages/web-ui/tsconfig.json index 04f1a75..4d8ebf0 100644 --- a/packages/web-ui/tsconfig.json +++ b/packages/web-ui/tsconfig.json @@ -4,7 +4,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@memo-code/types": ["../types/src/index.ts"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd23644..d23e5f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: packages/core: dependencies: + '@memo-code/types': + specifier: workspace:* + version: link:../types ignore: specifier: ^7.0.5 version: 7.0.5 @@ -149,6 +152,9 @@ importers: '@memo-code/tools': specifier: workspace:* version: link:../tools + '@memo-code/types': + specifier: workspace:* + version: link:../types ignore: specifier: ^7.0.5 version: 7.0.5 @@ -172,11 +178,13 @@ importers: specifier: ^19.2.14 version: 19.2.14 + packages/types: {} + packages/web-ui: dependencies: - '@memo-code/core': + '@memo-code/types': specifier: workspace:* - version: link:../core + version: link:../types '@streamdown/cjk': specifier: ^1.0.2 version: 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5) diff --git a/tsconfig.json b/tsconfig.json index 0b23b3e..efc3b75 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,8 @@ "paths": { "@memo/core": ["packages/core/src/index.ts"], "@memo/core/*": ["packages/core/src/*"], + "@memo-code/types": ["packages/types/src/index.ts"], + "@memo-code/types/*": ["packages/types/src/*"], "@memo/tools": ["packages/tools/src/index.ts"], "@memo/tools/*": ["packages/tools/src/*"], "@memo-code/tui": ["packages/tui/src/index.ts"], diff --git a/tsup.config.ts b/tsup.config.ts index 4452bdf..f866ed5 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' export default defineConfig({ entry: { index: 'packages/tui/src/cli.tsx', + 'core-server': 'packages/core/src/server/process_entry.ts', }, outDir: 'dist', format: ['esm'], @@ -24,7 +25,7 @@ export default defineConfig({ }, async onSuccess() { // Copy prompt.md to dist directory - copyFileSync(join('packages/core/src/runtime/prompt.md'), join('dist/prompt.md')) + copyFileSync(join('packages/core/src/runtime/prompt/prompt.md'), join('dist/prompt.md')) mkdirSync(join('dist/task-prompts'), { recursive: true }) cpSync(join('packages/tui/src/task-prompts'), join('dist/task-prompts'), { recursive: true,