diff --git a/README.md b/README.md index 6bcbb89..ee7db8c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Windows/macOS, repository-local distribution that connects the [Pi](https://gi ## 中文 -Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,通过根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和 Web 启动。 +Pi Agent Integrated 将 Pi 后端与 Pi Web 前端整合成一个可独立克隆、配置和启动的项目。它保留前后端源码边界,采用前后端分离架构:独立的后端服务(`server/`,常驻 30142 端口)托管 Pi 运行时、Agent 会话与全部 API,Next.js 前端(`pi-web/`,30141 端口)只负责界面并通过代理转发 `/api` 请求——前端热重载或重启不会中断运行中的 Agent 会话。根目录命令完成依赖安装、Pi 构建、插件安装、Profile 初始化和双进程启动。 ### 与两个源项目有什么不同 @@ -62,10 +62,11 @@ npm run dev 1. 初始化项目内 `data/` Profile; 2. 安装并构建本地 Pi 源码; -3. 安装 Pi Web 并连接本地 Pi 包; -4. 安装、固定并加载检查默认插件; -5. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置; -6. 检查前后端版本和构建产物。 +3. 安装 Pi Web 前端依赖; +4. 安装后端服务(`server/`)并链接本地 Pi 包; +5. 安装、固定并加载检查默认插件; +6. Windows 预缓存 Playwright MCP 包并确认系统 Edge 可用;macOS 跳过 Playwright 安装,浏览器自动化按需配置; +7. 检查前后端版本和构建产物。 `pi-smart-fetch` 使用仓库内的修复版,不再直接加载上游 `0.3.17` 发布包。setup 会在 `resources/packages/pi-smart-fetch/` 安装运行时依赖,并自动迁移旧的 `npm:pi-smart-fetch` 配置;该修复为 ESM 构建注入 `createRequire(import.meta.url)`,避免 Node/Next.js 加载 `mime-types` 时触发 `Dynamic require of "path" is not supported`。 @@ -120,7 +121,8 @@ Copy-Item .env.example .env ```text pi/ Pi 后端、Agent、CLI/TUI 源码 -pi-web/ Next.js Web 前端与 HTTP/SSE 服务 +server/ 独立后端服务(Hono + Node,托管 Pi 运行时与全部 /api 路由,端口 30142) +pi-web/ Next.js 纯前端(界面渲染,/api 经 rewrites 转发到后端,端口 30141) config/ 可提交的缺省设置、MCP 和子代理策略 resources/skills/ 应用级 Skill resources/extensions/ 应用级 Pi 扩展 @@ -156,7 +158,7 @@ data/workspaces/default/ 默认工作目录 Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。 -Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `pi-web/node_modules/.bin` 加入子进程 PATH,并把 Pi Web 使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。 +Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `server/node_modules/.bin` 加入子进程 PATH,并把后端服务使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。 #### 视觉子代理(vision) @@ -281,7 +283,7 @@ npm run storage:clean # 清空可重建缓存并删除全部孤儿检查点 ## English -Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries while root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and Web startup. +Pi Agent Integrated combines the Pi backend and Pi Web frontend into one independently cloneable and runnable repository. It preserves separate source boundaries and uses a decoupled frontend/backend architecture: a standalone backend service (`server/`, port 30142) hosts the Pi runtime, agent sessions, and every API route, while the Next.js frontend (`pi-web/`, port 30141) renders the UI and proxies `/api` requests to the backend — frontend hot reloads and restarts never interrupt running agent sessions. Root commands handle dependency installation, Pi builds, managed-profile creation, plugin installation, and dual-process startup. ### How it differs from the two upstream projects @@ -322,7 +324,7 @@ npm run dev Open . -Setup initializes the repository-local profile, builds Pi, links Pi Web to the local packages, installs and loads the pinned plugins, installs the repository-local repaired `pi-smart-fetch` dependencies, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts. +Setup initializes the repository-local profile, builds Pi, installs the Pi Web frontend dependencies, installs the backend service (`server/`) with links to the local Pi packages, installs and loads the pinned plugins, installs the repository-local repaired `pi-smart-fetch` dependencies, caches the Playwright MCP Node package and verifies system Edge on Windows, skips Playwright on macOS, and checks integration artifacts. The repaired `pi-smart-fetch` build is based on upstream `0.3.17`; it adds `createRequire(import.meta.url)` for the bundled CommonJS dependencies so Node/Next.js does not fail with `Dynamic require of "path" is not supported`. Existing managed profiles are migrated from `npm:pi-smart-fetch` to `resources/packages/pi-smart-fetch` automatically. @@ -375,7 +377,8 @@ Never commit `.env`, `data/`, real credentials, or screenshots containing creden ```text pi/ Pi backend, agent, and CLI/TUI source -pi-web/ Next.js frontend and HTTP/SSE service +server/ Standalone backend service (Hono + Node; hosts the Pi runtime and all /api routes; port 30142) +pi-web/ Next.js frontend only (UI rendering; /api rewritten to the backend; port 30141) config/ Versioned settings, MCP, and subagent policy resources/skills/ Application skills resources/extensions/ Application Pi extensions @@ -411,7 +414,7 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp. On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed. -On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `pi-web/node_modules/.bin` directory to the child PATH and links the Pi Web `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch. +On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `server/node_modules/.bin` directory to the child PATH and links the backend's `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch. #### Vision subagent diff --git a/package.json b/package.json index 13daca7..4073218 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "smoke": "node scripts/smoke.mjs", "smoke:fresh-profile": "node scripts/smoke-fresh-profile.mjs", "smoke:isolation": "node scripts/smoke-isolation.mjs", - "smoke:search": "node pi-web/scripts/searxng-smoke.mjs", + "smoke:search": "node server/test/searxng-smoke.mjs", "predev": "npm run check", "dev": "node scripts/run.mjs dev", "restart": "node scripts/restart.mjs", @@ -30,9 +30,9 @@ "storage:status": "node scripts/storage-maintenance.mjs status", "storage:maintain": "node scripts/storage-maintenance.mjs auto", "storage:clean": "node scripts/storage-maintenance.mjs clean", - "typecheck": "node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json", + "typecheck": "npm --prefix server run typecheck && node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json", "lint": "npm --prefix pi-web run lint", - "test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs" + "test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs server/test/searxng-extension.test.mjs server/src/lib/app-runtime.test.mjs server/src/lib/bash-output.test.mjs server/src/lib/bounded-form-data.test.mjs server/src/lib/custom-ui-terminal.test.mjs server/src/lib/directory-browser.test.mjs server/src/lib/file-access.test.mjs server/src/lib/file-dirent.test.mjs server/src/lib/file-upload.test.mjs server/src/lib/git-changes.test.mjs server/src/lib/models-cache.test.mjs server/src/lib/project-trust.test.mjs server/src/lib/request-security.test.mjs server/src/lib/rpc-manager.test.mjs server/src/lib/session-file-references.test.mjs server/src/lib/session-path.test.mjs server/src/lib/session-reader.test.mjs server/src/lib/session-title.test.mjs server/src/lib/skill-lock.test.mjs server/src/lib/skill-updates.test.mjs" }, "engines": { "node": ">=22.19.0" diff --git a/pi-web/AGENTS.md b/pi-web/AGENTS.md index 7afb390..bc577da 100644 --- a/pi-web/AGENTS.md +++ b/pi-web/AGENTS.md @@ -15,72 +15,51 @@ Lint: `npm run lint` ## Architecture ``` -Browser Next.js Server AgentSession (in-process) - │ │ │ - ├─ GET /api/sessions ────▶ reads ~/.pi/agent/sessions/ │ - ├─ GET /api/sessions/[id] reads .jsonl file directly │ - ├─ GET /api/agent/running/events ───▶ running id SSE │ - │ │ │ - ├─ send message ─────────▶ POST /api/agent/[id] │ - │ │ startRpcSession() ─────────▶│ createAgentSession() - │ │ session.send(cmd) ─────────▶│ session.prompt() - │ │ │ - ├─ SSE connect ──────────▶ GET /api/agent/[id]/events │ - │ │ session.onEvent() ◀─────────│ session.subscribe() - │◀── data: {...} ─────────│ │ +Browser Next.js frontend Pi Agent Server + │ │ │ + ├─ GET/POST /api/* ────▶│ rewrite to :30142 ─────▶│ Hono routes + ├─ SSE connect ────────▶│ transparent proxy ─────▶│ AgentSession events + │◀── data: {...} ───────│◀────────────────────────│ ``` -**Session browsing** (read-only): reads `.jsonl` files through SDK `SessionManager` helpers and `lib/session-reader.ts` — no AgentSession created. -**Sending a message**: `startRpcSession()` in `lib/rpc-manager.ts` creates an AgentSession in-process. +`pi-web/` owns only React/Next.js UI code. All filesystem access, authentication, +configuration, Pi packages, API routes, SSE streams, and AgentSession state live +in `../server/`. The frontend proxies `/api/*` through `next.config.ts`; restarting +or hot-reloading Next.js must not interrupt an active Agent session. + +**Session browsing** (read-only): the backend reads `.jsonl` files through SDK +`SessionManager` helpers and `server/src/lib/session-reader.ts`. +**Sending a message**: `startRpcSession()` in `server/src/lib/rpc-manager.ts` +creates and retains the AgentSession in the standalone backend process. --- ## File Map ``` -app/api/ - sessions/route.ts GET list all sessions - sessions/[id]/route.ts GET/PATCH/DELETE session - sessions/[id]/context/route.ts GET ?leafId= — context for a specific leaf - sessions/[id]/export/route.ts GET exported HTML for a session - agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? } - agent/[id]/route.ts GET state | POST any command - agent/[id]/events/route.ts GET SSE stream - agent/running/events/route.ts GET SSE stream of currently-running session ids - auth/all-providers/route.ts GET API-key provider list - auth/api-key/[provider]/route.ts GET/POST/DELETE provider API key status/storage - auth/login/[provider]/route.ts GET OAuth/device-code SSE | POST manual code - auth/logout/[provider]/route.ts POST OAuth logout - auth/providers/route.ts GET OAuth provider list - cwd/validate/route.ts POST validate/select a cwd - default-cwd/route.ts POST create managed data/workspaces/default (standalone: ~/pi-cwd-YYYYMMDD) - files/[...path]/route.ts GET file contents for viewer - home/route.ts GET user home directory - models/route.ts GET { models, modelList, defaultModel } - models-config/route.ts GET/PUT — read/write ~/.pi/agent/models.json - models-config/catalog/route.ts GET models.dev pricing presets - models-config/discover/route.ts POST fetch a configured provider's upstream model list - models-config/test/route.ts POST test a configured model/provider - plugins/route.ts GET/POST package plugin management - skills/route.ts GET/PATCH loaded skills and disable-model-invocation - skills/install/route.ts POST install skills through npx skills add - skills/search/route.ts GET/POST skills.sh search - worktrees/route.ts GET/POST/DELETE git worktrees +../server/src/ + index.ts Hono server, health endpoint, route registration + security.ts host/origin gate for every /api request + routes/agent.ts Agent creation, commands, state, and SSE + routes/auth.ts OAuth/device-code and API-key routes + routes/config.ts extensions, MCP, vision, and plugin configuration + routes/files.ts guarded file reading, preview, download, and upload + routes/misc.ts cwd, runtime, file index, worktrees, and project trust + routes/models.ts Git state, models, discovery, catalog, and tests + routes/sessions.ts session listing, context, naming, export, and deletion + routes/skills.ts skill listing, search, install, and update + lib/rpc-manager.ts AgentSessionWrapper registry and startRpcSession() + lib/session-reader.ts SessionManager wrappers and session path cache lib/ agent-client.ts typed fetch helper for /api/agent commands draft-store.ts local draft persistence helpers - file-access.ts allowed file roots for /api/files and worktrees - file-paths.ts client/server path encoding helpers + file-paths.ts client-side path encoding helpers markdown.ts shared markdown helpers - npx.ts npx runner used by skill install - pi-types.ts local structural types for pi SDK objects - rpc-manager.ts AgentSessionWrapper + registry + startRpcSession - session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter + pi-types.ts structural copies of API/session types; no Pi dependency tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools() types.ts shared TypeScript types normalize.ts normalizeToolCalls() — field name mismatch between file format and our types - worktree.ts project/worktree resolution and git worktree operations components/ AppShell.tsx layout + URL state + tab management @@ -111,9 +90,9 @@ hooks/ ## Key Design Decisions & Traps -### AgentSession lifecycle (`lib/rpc-manager.ts`) +### AgentSession lifecycle (`server/src/lib/rpc-manager.ts`) - One `AgentSessionWrapper` per session id, keyed in `globalThis.__piSessions` -- `globalThis` survives Next.js hot-reload; plain module-level Map does not +- The standalone backend process survives Next.js hot reloads and restarts - Idle timeout: 10 minutes. Concurrent `startRpcSession()` calls share a single start Promise (`globalThis.__piStartLocks`) ### Fork must destroy the wrapper immediately @@ -144,12 +123,12 @@ On `ChatWindow` mount, `GET /api/agent/[id]` is called. If `state.isStreaming == Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `auto_compaction_start` / `auto_compaction_end`. `handleAgentEvent` accepts both sets to keep `isCompacting` in sync. Manual compact is a blocking POST — the button stays disabled until the response returns. ### Running state SSE + reconciliation -- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `lib/rpc-manager.ts`, so running badges update without polling. +- The sidebar listens to `/api/agent/running/events`, backed by `subscribeRunningSessions()` in `server/src/lib/rpc-manager.ts`, so running badges update without polling. - `useAgentSession` still treats per-session SSE as primary for chat events, but while a run is active it periodically calls `GET /api/agent/[id]` and also reconciles on `visibilitychange`/`online`. This fixes missed `agent_end` events from background tabs or half-open connections. - Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles. ### Worktrees and project grouping -- `lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar. +- `server/src/lib/worktree.ts` resolves linked worktree top-levels back to the main repo `projectRoot`; `listAllSessions()` attaches that to each `SessionInfo` so all worktrees for one repo are grouped together in the sidebar. - Worktree operations are served by `/api/worktrees` and guarded by the same allowed-root rules as `/api/files`. - New worktrees are created under `-worktrees/`. Existing branches are reused; otherwise `git worktree add -b` creates the branch. - Removing a dirty worktree returns `409` with `{ dirty: true }` so the UI can ask before retrying with `force`. @@ -169,7 +148,7 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au - `ModelsConfig` combines models from `~/.pi/agent/models.json` with provider auth status from pi's `AuthStorage`/`ModelRegistry`. - OAuth/device-code/manual-code flows are streamed by `GET /api/auth/login/[provider]`; manual code responses POST back with a short-lived token stored in `globalThis.__piLoginCallbacks`. - API-key routes store and remove keys through `AuthStorage`. Status endpoints must never return the raw key. -- The model test route is `app/api/models-config/test/route.ts`; `app/api/models/test/` is not a real route. +- The model test endpoint is registered in `server/src/routes/models.ts` as `/api/models-config/test`; `/api/models/test` is not a real route. ### Completion sound - `hooks/useAudio.ts` stores the toggle in `localStorage` as `pi-sound-enabled` and reuses one `AudioContext`. diff --git a/pi-web/app/api/agent/[id]/bash-output/route.ts b/pi-web/app/api/agent/[id]/bash-output/route.ts deleted file mode 100644 index 99a504a..0000000 --- a/pi-web/app/api/agent/[id]/bash-output/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { NextResponse } from "next/server"; -import { tmpdir } from "node:os"; -import { Readable } from "node:stream"; -import { - MAX_INLINE_BASH_OUTPUT_BYTES, - openRegularFileNoFollow, - readUtf8FileWithinLimit, - resolveBashOutputPath, -} from "@/lib/bash-output"; -import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references"; - -// GET /api/agent/[id]/bash-output?path= -// Reads a bash output temp file referenced by this session. Inline display is -// size-limited; download responses stream the file without buffering it. -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - let path: string | null = null; - let download = false; - try { - const url = new URL(_req.url); - path = url.searchParams.get("path"); - download = url.searchParams.get("download") === "1"; - } catch { - return NextResponse.json({ error: "invalid url" }, { status: 400 }); - } - - if (!path) { - return NextResponse.json({ error: "path required" }, { status: 400 }); - } - - const resolved = resolveBashOutputPath(path, tmpdir()); - if (!resolved) { - return NextResponse.json({ error: "invalid path" }, { status: 400 }); - } - - if (!await isBashOutputPathReferencedBySession(resolved, id)) { - return NextResponse.json({ error: "forbidden" }, { status: 403 }); - } - - try { - if (download) { - const { handle } = await openRegularFileNoFollow(resolved); - const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream; - return new Response(stream, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": "attachment; filename=\"bash-output.log\"", - "Cache-Control": "no-store", - }, - }); - } - - const result = await readUtf8FileWithinLimit(resolved); - if (result.tooLarge) { - return NextResponse.json({ - error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`, - data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES }, - }, { status: 413 }); - } - return NextResponse.json({ success: true, data: { output: result.content } }); - } catch { - return NextResponse.json({ error: "full output unavailable" }, { status: 404 }); - } -} diff --git a/pi-web/app/api/agent/[id]/events/route.ts b/pi-web/app/api/agent/[id]/events/route.ts deleted file mode 100644 index 324eb2a..0000000 --- a/pi-web/app/api/agent/[id]/events/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { resolveSessionPath } from "@/lib/session-reader"; -import { getRpcSession, startRpcSession } from "@/lib/rpc-manager"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; - -export const dynamic = "force-dynamic"; - -// GET /api/agent/[id]/events - SSE stream of agent events -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - // Fast path: already-running session - let session = getRpcSession(id); - if (!session || !session.isAlive()) { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return new Response("Session not found", { status: 404 }); - } - const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); - try { - ({ session } = await startRpcSession(id, filePath, cwd)); - } catch (error) { - return new Response(`Failed to start agent: ${error}`, { status: 500 }); - } - } - - const stream = new ReadableStream({ - start(controller) { - const encode = (data: unknown) => { - const text = `data: ${JSON.stringify(data)}\n\n`; - controller.enqueue(new TextEncoder().encode(text)); - }; - - // Send initial connected event - encode({ type: "connected", sessionId: id }); - - const unsubscribe = session.onEvent((event) => { - encode(event); - }); - - // Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s) - const heartbeat = setInterval(() => { - try { - controller.enqueue(new TextEncoder().encode(":\n\n")); - } catch { - // controller already closed - } - }, 30_000); - - // Cleanup when client disconnects - const cleanup = () => { - clearInterval(heartbeat); - unsubscribe(); - controller.close(); - }; - - // Detect client disconnect via abort signal - req.signal?.addEventListener("abort", cleanup); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/pi-web/app/api/agent/[id]/route.ts b/pi-web/app/api/agent/[id]/route.ts deleted file mode 100644 index 100fdec..0000000 --- a/pi-web/app/api/agent/[id]/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; -import { resolveSessionPath } from "@/lib/session-reader"; -import { startRpcSession, getRpcSession } from "@/lib/rpc-manager"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; - -// POST /api/agent/[id] - Send a command to an existing session -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - const body = await req.json() as { type: string; [key: string]: unknown }; - - // Fast path: already-running session - const existing = getRpcSession(id); - if (existing?.isAlive()) { - const result = await existing.send(body); - return NextResponse.json({ success: true, data: result }); - } - - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); - - const { session } = await startRpcSession(id, filePath, cwd); - const result = await session.send(body); - - return NextResponse.json({ success: true, data: result }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// GET /api/agent/[id] - Get current agent state -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - const session = getRpcSession(id); - if (!session || !session.isAlive()) { - return NextResponse.json({ running: false }); - } - - const state = await session.send({ type: "get_state" }); - return NextResponse.json({ running: true, state }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/agent/new/route.ts b/pi-web/app/api/agent/new/route.ts deleted file mode 100644 index dc2d9c5..0000000 --- a/pi-web/app/api/agent/new/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NextResponse } from "next/server"; -import { existsSync } from "fs"; -import { randomUUID } from "crypto"; -import { allowFileRoot } from "@/lib/file-access"; -import { invalidateSessionListCache } from "@/lib/session-reader"; -import { startRpcSession } from "@/lib/rpc-manager"; -// POST /api/agent/new body: { cwd: string; type: string; message?: string; ... } -// Spawns a brand-new pi session. Most calls immediately send the first command; -// type:"ensure_session" only creates the runtime so clients can query commands. -// Returns { sessionId, data } where sessionId is pi's real session id. -export async function POST(req: Request) { - try { - const body = await req.json() as { cwd?: string; [key: string]: unknown }; - const { cwd, ...command } = body; - - if (!cwd || typeof cwd !== "string") { - return NextResponse.json({ error: "cwd is required" }, { status: 400 }); - } - if (!existsSync(cwd)) { - return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 }); - } - - // Use a one-time key so startRpcSession's lock doesn't conflict with real session ids - const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown }; - - // Must be unique per request: startRpcSession coalesces concurrent callers - // that share a key onto one session. Date.now() (ms resolution) collides for - // requests in the same millisecond, merging two new sessions into one. - const tempKey = `__new__${randomUUID()}`; - const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames); - - // Keep the files-route allowed-roots cache (see app/api/files/[...path]/route.ts) - // in sync so the new cwd is immediately readable via /api/files. Without this, - // a file request under a brand-new cwd would 403 for up to the cache TTL. - allowFileRoot(cwd); - invalidateSessionListCache(); - - // Apply pre-selected model before sending the prompt - if (provider && modelId) { - await session.send({ type: "set_model", provider, modelId }); - } - - // Apply pre-selected thinking level before sending the prompt - if (thinkingLevel) { - await session.send({ type: "set_thinking_level", level: thinkingLevel }); - } - - if (promptCommand.type === "ensure_session") { - return NextResponse.json({ success: true, sessionId: realSessionId, data: null }); - } - - const result = await session.send(promptCommand); - - return NextResponse.json({ success: true, sessionId: realSessionId, data: result }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/agent/running/events/route.ts b/pi-web/app/api/agent/running/events/route.ts deleted file mode 100644 index 811ab51..0000000 --- a/pi-web/app/api/agent/running/events/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getRunningRpcSessionIds, subscribeRunningSessions } from "@/lib/rpc-manager"; - -export const dynamic = "force-dynamic"; - -// GET /api/agent/running/events - SSE stream of the set of currently-running -// session ids. Pushes an update whenever any session starts or stops working, -// so the sidebar never has to poll. -export async function GET(req: Request) { - const stream = new ReadableStream({ - start(controller) { - const encode = (data: unknown) => { - const text = `data: ${JSON.stringify(data)}\n\n`; - controller.enqueue(new TextEncoder().encode(text)); - }; - - // Subscribe BEFORE taking the initial snapshot so no state change can slip - // through the gap between snapshot and subscription. - const unsubscribe = subscribeRunningSessions((ids) => { - try { - encode({ type: "running", runningSessionIds: ids }); - } catch { - // controller already closed - } - }); - - // Initial snapshot so the client renders the correct state immediately. - // (A duplicate frame here is harmless: the client just sets the same set.) - encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() }); - - // Heartbeat to keep the connection alive through proxies/timeouts. - const heartbeat = setInterval(() => { - try { - controller.enqueue(new TextEncoder().encode(":\n\n")); - } catch { - // controller already closed - } - }, 30_000); - - const cleanup = () => { - clearInterval(heartbeat); - unsubscribe(); - try { controller.close(); } catch { /* already closed */ } - }; - - req.signal?.addEventListener("abort", cleanup); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/pi-web/app/api/auth/all-providers/route.ts b/pi-web/app/api/auth/all-providers/route.ts deleted file mode 100644 index 0e3de10..0000000 --- a/pi-web/app/api/auth/all-providers/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; - -export const dynamic = "force-dynamic"; - -// Providers that use OAuth — handled separately via /api/auth/providers -const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]); - -export async function GET() { - const modelRuntime = await ModelRuntime.create(); - const all = modelRuntime.getModels(); - - // Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key) - const seen = new Set(); - const result: { - id: string; - displayName: string; - configured: boolean; - source?: string; - modelCount: number; - }[] = []; - - for (const provider of modelRuntime.getProviders()) { - if (seen.has(provider.id)) continue; - seen.add(provider.id); - if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue; - const status = modelRuntime.getProviderAuthStatus(provider.id); - // Skip providers whose key comes from models.json (those are custom providers) - if (status.source === "models_json_key") continue; - const modelCount = all.filter((model) => model.provider === provider.id).length; - result.push({ - id: provider.id, - displayName: provider.name, - configured: status.configured, - source: status.source, - modelCount, - }); - } - - return Response.json({ providers: result }); -} diff --git a/pi-web/app/api/auth/api-key/[provider]/route.ts b/pi-web/app/api/auth/api-key/[provider]/route.ts deleted file mode 100644 index 7fbef96..0000000 --- a/pi-web/app/api/auth/api-key/[provider]/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { NextResponse } from "next/server"; -import { invalidateModelsCache } from "@/lib/models-cache"; - -export const dynamic = "force-dynamic"; - -type Params = { params: Promise<{ provider: string }> }; - -// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key) -export async function GET(_req: Request, { params }: Params) { - const { provider } = await params; - const modelRuntime = await ModelRuntime.create(); - const status = modelRuntime.getProviderAuthStatus(provider); - const displayName = modelRuntime.getProvider(provider)?.name ?? provider; - const models = modelRuntime.getModels(provider).length; - return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models }); -} - -// POST /api/auth/api-key/[provider] body: { apiKey: string } -export async function POST(req: Request, { params }: Params) { - const { provider } = await params; - try { - const { apiKey } = await req.json() as { apiKey?: string }; - if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { - return NextResponse.json({ error: "apiKey is required" }, { status: 400 }); - } - const modelRuntime = await ModelRuntime.create(); - let keySubmitted = false; - await modelRuntime.login(provider, "api_key", { - notify: () => {}, - prompt: async (prompt) => { - if (prompt.type === "select") { - const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token"); - if (keyOption) return keyOption.id; - throw new Error(`${provider} requires interactive authentication setup`); - } - if (!keySubmitted && prompt.type === "secret") { - keySubmitted = true; - return apiKey.trim(); - } - throw new Error(`${provider} requires additional authentication settings`); - }, - }); - invalidateModelsCache(); - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// DELETE /api/auth/api-key/[provider] — removes stored API key -export async function DELETE(_req: Request, { params }: Params) { - const { provider } = await params; - try { - const modelRuntime = await ModelRuntime.create(); - await modelRuntime.logout(provider); - invalidateModelsCache(); - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/auth/login/[provider]/route.ts b/pi-web/app/api/auth/login/[provider]/route.ts deleted file mode 100644 index 5c1dbc0..0000000 --- a/pi-web/app/api/auth/login/[provider]/route.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { invalidateModelsCache } from "@/lib/models-cache"; - -export const dynamic = "force-dynamic"; - -// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise -declare global { - var __piLoginCallbacks: Map void; reject: (e: Error) => void }> | undefined; -} - -function getCallbackRegistry() { - if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map(); - return globalThis.__piLoginCallbacks; -} - -// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code -export async function POST( - req: Request, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - const { token, code } = (await req.json()) as { token?: string; code?: string }; - - if (!token || !code) { - return Response.json({ error: "token and code required" }, { status: 400 }); - } - - const registry = getCallbackRegistry(); - const callbacks = registry.get(token); - if (!callbacks) { - return Response.json({ error: "No pending login for token" }, { status: 404 }); - } - // Verify token belongs to this provider (token format: "--") - if (!token.startsWith(`${provider}-`)) { - return Response.json({ error: "Token does not match provider" }, { status: 400 }); - } - - callbacks.resolve(code); - registry.delete(token); - return Response.json({ ok: true, provider }); -} - -// GET /api/auth/login/[provider] — SSE stream for OAuth flow -export async function GET( - req: Request, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - - const encoder = new TextEncoder(); - const send = (controller: ReadableStreamDefaultController, data: unknown) => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); - }; - - // AbortController propagates client disconnect into ModelRuntime.login(). - const abort = new AbortController(); - req.signal.addEventListener("abort", () => abort.abort()); - - const stream = new ReadableStream({ - async start(controller) { - const modelRuntime = await ModelRuntime.create(); - if (!modelRuntime.getProvider(provider)?.auth.oauth) { - send(controller, { type: "error", message: `Unknown provider: ${provider}` }); - controller.close(); - return; - } - - const registry = getCallbackRegistry(); - const activeTokens = new Set(); - let pendingManualRequest: { token: string; promise: Promise } | undefined; - - const createClientInputRequest = () => { - const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`; - activeTokens.add(token); - - const promise = new Promise((resolve, reject) => { - registry.set(token, { - resolve: (value) => { - activeTokens.delete(token); - registry.delete(token); - resolve(value); - }, - reject: (error) => { - activeTokens.delete(token); - registry.delete(token); - reject(error); - }, - }); - }); - - return { token, promise }; - }; - - const getManualInputRequest = () => { - if (!pendingManualRequest) { - pendingManualRequest = createClientInputRequest(); - pendingManualRequest.promise - .finally(() => { - pendingManualRequest = undefined; - }) - .catch(() => {}); - } - return pendingManualRequest; - }; - - // Cleanup: remove pending token and abort any waiting promise - const cleanup = () => { - for (const token of activeTokens) { - registry.get(token)?.reject(new Error("Login cancelled")); - registry.delete(token); - } - activeTokens.clear(); - }; - - // Also cancel on client disconnect - abort.signal.addEventListener("abort", cleanup); - - try { - await modelRuntime.login(provider, "oauth", { - prompt: async (prompt: AuthPrompt) => { - const request = prompt.type === "manual_code" - ? getManualInputRequest() - : createClientInputRequest(); - if (prompt.type === "select") { - send(controller, { - type: "select_request", - message: prompt.message, - options: prompt.options, - token: request.token, - }); - } else { - send(controller, { - type: "prompt_request", - message: prompt.message, - placeholder: prompt.placeholder ?? null, - token: request.token, - }); - } - return request.promise; - }, - notify: (event: AuthEvent) => { - if (event.type === "auth_url") { - const request = getManualInputRequest(); - send(controller, { - type: "auth", - url: event.url, - instructions: event.instructions ?? null, - token: request.token, - }); - } else if (event.type === "device_code") { - send(controller, { - type: "device_code", - userCode: event.userCode, - verificationUri: event.verificationUri, - intervalSeconds: event.intervalSeconds ?? null, - expiresInSeconds: event.expiresInSeconds ?? null, - }); - } else { - send(controller, { type: "progress", message: event.message }); - } - }, - signal: abort.signal, - }); - - invalidateModelsCache(); - send(controller, { type: "success" }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg !== "Login cancelled") { - send(controller, { type: "error", message: msg }); - } else { - send(controller, { type: "cancelled" }); - } - } finally { - cleanup(); - controller.close(); - } - }, - cancel() { - abort.abort(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/pi-web/app/api/auth/logout/[provider]/route.ts b/pi-web/app/api/auth/logout/[provider]/route.ts deleted file mode 100644 index c5e63b2..0000000 --- a/pi-web/app/api/auth/logout/[provider]/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { invalidateModelsCache } from "@/lib/models-cache"; - -export const dynamic = "force-dynamic"; - -export async function POST( - _req: Request, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - const modelRuntime = await ModelRuntime.create(); - if (!modelRuntime.getProvider(provider)?.auth.oauth) { - return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 }); - } - await modelRuntime.logout(provider); - invalidateModelsCache(); - return Response.json({ ok: true }); -} diff --git a/pi-web/app/api/auth/providers/route.ts b/pi-web/app/api/auth/providers/route.ts deleted file mode 100644 index 4879d8b..0000000 --- a/pi-web/app/api/auth/providers/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - const modelRuntime = await ModelRuntime.create(); - const credentials = await modelRuntime.listCredentials(); - const loggedInProviders = new Set( - credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId), - ); - const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth); - - const EXCLUDED = new Set(["anthropic"]); - const DISPLAY_NAMES: Record = { - "openai-codex": "ChatGPT Plus/Pro", - "github-copilot": "GitHub Copilot", - }; - - const result = await Promise.all( - providers - .filter((p) => !EXCLUDED.has(p.id)) - .map(async (p) => { - return { - id: p.id, - name: DISPLAY_NAMES[p.id] ?? p.name, - usesCallbackServer: false, - loggedIn: loggedInProviders.has(p.id), - }; - }) - ); - - return Response.json({ providers: result }); -} diff --git a/pi-web/app/api/cwd/browse/route.ts b/pi-web/app/api/cwd/browse/route.ts deleted file mode 100644 index 3f99325..0000000 --- a/pi-web/app/api/cwd/browse/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { stat } from "fs/promises"; -import { - getBrowseStartDirectory, - getParentDirectory, - listDirectories, - resolveDirectory, -} from "@/lib/directory-browser"; - -// GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。 -export async function GET(request: NextRequest) { - try { - const requested = request.nextUrl.searchParams.get("path")?.trim(); - const candidate = getBrowseStartDirectory(requested); - - let resolved: string; - try { - resolved = await resolveDirectory(candidate); - } catch { - return NextResponse.json({ error: "Directory does not exist" }, { status: 404 }); - } - - - const directoryStat = await stat(resolved); - if (!directoryStat.isDirectory()) { - return NextResponse.json({ error: "Path is not a directory" }, { status: 400 }); - } - - const directories = await listDirectories(resolved); - - return NextResponse.json({ - path: resolved, - parentPath: getParentDirectory(resolved), - directories, - }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/cwd/validate/route.ts b/pi-web/app/api/cwd/validate/route.ts deleted file mode 100644 index df87ebf..0000000 --- a/pi-web/app/api/cwd/validate/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextResponse } from "next/server"; -import { statSync, type Stats } from "fs"; -import { homedir } from "os"; -import { isAbsolute, resolve } from "path"; -import { allowFileRoot } from "@/lib/file-access"; - -function normalizeCwd(cwd: string): string { - if (cwd === "~") return homedir(); - if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2)); - return isAbsolute(cwd) ? cwd : resolve(cwd); -} - -// POST /api/cwd/validate body: { cwd: string } -// Validates a candidate workspace before the UI selects it. -export async function POST(req: Request) { - try { - const body = await req.json() as { cwd?: unknown }; - const cwd = typeof body.cwd === "string" ? body.cwd.trim() : ""; - - if (!cwd) { - return NextResponse.json({ error: "Path is required" }, { status: 400 }); - } - - const normalizedCwd = normalizeCwd(cwd); - let stat: Stats; - try { - stat = statSync(normalizedCwd); - } catch { - return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 }); - } - - if (!stat.isDirectory()) { - return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 }); - } - - allowFileRoot(normalizedCwd); - return NextResponse.json({ success: true, cwd: normalizedCwd }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/default-cwd/route.ts b/pi-web/app/api/default-cwd/route.ts deleted file mode 100644 index a05af45..0000000 --- a/pi-web/app/api/default-cwd/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { mkdirSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; -import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime"; -import { allowFileRoot } from "@/lib/file-access"; - -// POST /api/default-cwd -// Managed app: creates /workspaces/default. -// Standalone pi-web: preserves the upstream ~/pi-cwd- behavior. -export async function POST() { - try { - const dir = isManagedRuntime() - ? join(getManagedRuntimePaths().dataDir, "workspaces", "default") - : join( - homedir(), - `pi-cwd-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`, - ); - mkdirSync(dir, { recursive: true }); - allowFileRoot(dir); - return NextResponse.json({ cwd: dir }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/extensions/route.ts b/pi-web/app/api/extensions/route.ts deleted file mode 100644 index 97e23b2..0000000 --- a/pi-web/app/api/extensions/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { existsSync, readdirSync, statSync } from "fs"; -import { basename, dirname, extname, join, relative, resolve } from "path"; -import { DefaultPackageManager, getAgentDir, type ResolvedResource } from "@earendil-works/pi-coding-agent"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { getManagedRuntimePaths, isManagedRuntime, createAppSettingsManager } from "@/lib/app-runtime"; -import { getProjectTrustStatus } from "@/lib/project-trust"; -import type { ExtensionInfo, ExtensionsResponse, PluginDiagnostic } from "@/lib/api-types"; - -export const dynamic = "force-dynamic"; - -function extensionName(path: string): string { - const file = basename(path); - const extension = extname(file); - if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path)); - return extension ? file.slice(0, -extension.length) : file; -} - -function extensionFiles(root: string): string[] { - if (!existsSync(root)) return []; - try { - const stats = statSync(root); - if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : []; - if (!stats.isDirectory()) return []; - } catch { - return []; - } - - const files: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (entry.isDirectory()) files.push(...extensionFiles(path)); - else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(path); - } - return files; -} - -function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo { - const baseDir = resource.metadata.baseDir ?? dirname(resource.path); - const rel = relative(baseDir, resource.path); - return { - name: extensionName(resource.path), - path: resource.path, - relativePath: rel && !rel.startsWith("..") ? rel : resource.path, - source: scopeOverride === "builtin" ? "resources" : resource.metadata.source, - scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"), - status: resource.enabled ? "enabled" : "disabled", - }; -} - -function blockedInfo(path: string): ExtensionInfo { - return { - name: extensionName(path), - path, - relativePath: relative(dirname(dirname(path)), path), - source: "project", - scope: "project", - status: "blocked", - }; -} - -export async function GET(req: Request) { - let cwd: string | null; - try { - cwd = new URL(req.url).searchParams.get("cwd"); - } catch { - return Response.json({ error: "invalid request URL" }, { status: 400 }); - } - if (!cwd) return Response.json({ error: "cwd required" }, { status: 400 }); - - try { - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - - const agentDir = getAgentDir(); - const trust = getProjectTrustStatus(cwd, agentDir); - const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted); - const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); - const diagnostics: PluginDiagnostic[] = []; - const byPath = new Map(); - - try { - const resolved = await packageManager.resolve(async () => "skip"); - for (const resource of resolved.extensions) { - // Package-provided extensions belong to the plugin panel, not here. - if (resource.metadata.origin !== "top-level") continue; - byPath.set(resource.path, infoFromResource(resource)); - } - } catch (error) { - diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) }); - } - - if (isManagedRuntime()) { - const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions"); - const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true }); - for (const resource of builtIn.extensions) { - byPath.set(resource.path, infoFromResource(resource, "builtin")); - } - } - - const projectExtensionsRoot = join(cwd, ".pi", "extensions"); - if (trust.requiresTrust && !trust.trusted) { - for (const path of extensionFiles(projectExtensionsRoot)) { - byPath.set(path, blockedInfo(path)); - } - if (byPathHasScope(byPath, "project")) { - diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." }); - } - } - - const scopeOrder: Record = { project: 0, builtin: 1, global: 2 }; - const extensions = [...byPath.values()].sort((a, b) => ( - scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path) - )); - return Response.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse); - } catch (error) { - return Response.json({ error: String(error) }, { status: 500 }); - } -} - -function byPathHasScope(entries: Map, scope: ExtensionInfo["scope"]): boolean { - for (const entry of entries.values()) { - if (entry.scope === scope) return true; - } - return false; -} diff --git a/pi-web/app/api/file-index/route.ts b/pi-web/app/api/file-index/route.ts deleted file mode 100644 index 3030015..0000000 --- a/pi-web/app/api/file-index/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { execFile } from "child_process"; -import { promisify } from "util"; -import fs from "fs"; -import path from "path"; -import { - getAllowedFileRoots, - isExistingFilePathAllowed, - isFilePathAllowed, - isWindowsAbsolutePath, -} from "@/lib/file-access"; -import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy"; - -const execFileAsync = promisify(execFile); - -// Same skip lists as /api/files — only used for the non-git readdir fallback. -// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior). -const IGNORED_NAMES = new Set([ - "node_modules", ".git", ".next", "dist", "build", "__pycache__", - ".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache", - "target", "vendor", ".DS_Store", -]); - -const IGNORED_SUFFIXES = [".pyc"]; - -/** Cap on the plain (no-query) response used as the client-side index */ -const MAX_FILES = 5000; -/** Hard caps on the full in-memory listing that ?q= searches against */ -const GIT_HARD_CAP = 200_000; -const WALK_HARD_CAP = 50_000; -const MAX_WALK_DEPTH = 8; -const MAX_QUERY_LENGTH = 500; -const CACHE_TTL_MS = 10_000; -const CACHE_MAX_ENTRIES = 20; - -interface FileListing { - /** Full listing up to the hard cap (not the client cap) */ - files: string[]; - /** True when even the hard cap was exceeded */ - hardTruncated: boolean; -} - -interface CacheEntry { - listing: FileListing; - /** Derived lazily on the first ?q= search against this listing */ - entries?: FileIndexEntry[]; - expiresAt: number; -} - -// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu -// re-requests on every open and searches on every keystroke, so listings must -// not be recomputed within a short window. -declare global { - var __piFileIndexCache: Map | undefined; -} - -function getIndexCache(): Map { - if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map(); - return globalThis.__piFileIndexCache; -} - -async function listWithGit(cwd: string): Promise { - try { - const { stdout } = await execFileAsync( - "git", - ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], - { timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, - ); - const all = stdout.split("\0").filter(Boolean); - if (all.length > GIT_HARD_CAP) { - return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true }; - } - return { files: all, hardTruncated: false }; - } catch { - // Not a git repo (or git unavailable) — caller falls back to readdir walk. - return null; - } -} - -function listWithWalk(cwd: string): FileListing { - const files: string[] = []; - // BFS so shallow files win when the cap truncates the listing. - const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }]; - while (queue.length > 0) { - const { abs, rel, depth } = queue.shift()!; - let dirents: fs.Dirent[]; - try { - dirents = fs.readdirSync(abs, { withFileTypes: true }); - } catch { - continue; - } - for (const d of dirents) { - if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue; - const childRel = rel ? `${rel}/${d.name}` : d.name; - if (d.isDirectory()) { - if (depth + 1 <= MAX_WALK_DEPTH) { - queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 }); - } - } else if (d.isFile()) { - if (files.length >= WALK_HARD_CAP) { - return { files, hardTruncated: true }; - } - files.push(childRel); - } - } - } - return { files, hardTruncated: false }; -} - -// GET /api/file-index?cwd=/abs/path[&q=query] -// Without q: { files: string[] (relative to cwd, capped at MAX_FILES), -// truncated: boolean } — the client-side index for local filtering. -// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so -// repos larger than MAX_FILES still find deep files (cap applied after -// matching, like the TUI passing the query to fd). -// Guarded by the same allow-list as /api/files. -export async function GET(req: NextRequest) { - try { - const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? ""; - if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { - return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); - } - const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? ""; - - const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - let stat: fs.Stats; - try { - stat = fs.statSync(cwd); - } catch { - return NextResponse.json({ error: "Directory not found" }, { status: 404 }); - } - if (!stat.isDirectory()) { - return NextResponse.json({ error: "Not a directory" }, { status: 400 }); - } - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const cache = getIndexCache(); - const now = Date.now(); - let cached = cache.get(cwd); - if (!cached || cached.expiresAt <= now) { - const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd); - for (const [key, entry] of cache) { - if (entry.expiresAt <= now) cache.delete(key); - } - if (cache.size >= CACHE_MAX_ENTRIES) cache.clear(); - cached = { listing, expiresAt: now + CACHE_TTL_MS }; - cache.set(cwd, cached); - } - - if (query) { - cached.entries ??= buildEntriesFromFiles(cached.listing.files); - return NextResponse.json({ matches: filterFileEntries(cached.entries, query) }); - } - - const { files, hardTruncated } = cached.listing; - return NextResponse.json({ - files: files.slice(0, MAX_FILES), - truncated: hardTruncated || files.length > MAX_FILES, - }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/files/[...path]/route.ts b/pi-web/app/api/files/[...path]/route.ts deleted file mode 100644 index ed1356d..0000000 --- a/pi-web/app/api/files/[...path]/route.ts +++ /dev/null @@ -1,702 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; -import { - getAllowedFileRoots, - isExistingFilePathAllowed, - isFilePathAllowed, - isWindowsAbsolutePath, - normalizeSlashes, -} from "@/lib/file-access"; -import { - DOCX_PREVIEW_MAX_BYTES, - IMAGE_PREVIEW_MAX_BYTES, - TEXT_PREVIEW_MAX_BYTES, - documentPreviewKind, - getAudioMime, - getDocumentMime, - getFileExt, - getImageMime, - isAudioPath, - isDocumentPreviewPath, - isImagePath, -} from "@/lib/file-types"; -import { resolveDirentIsDirectory } from "@/lib/file-dirent"; -import { isFilePathReferencedBySession } from "@/lib/session-file-references"; -import { isApiRequestAllowed } from "@/lib/request-security"; -import { - inspectUploadTargets, - parseUploadConflictStrategy, - validateUploadFileNames, -} from "@/lib/file-upload"; -import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; - -const IGNORED_NAMES = new Set([ - "node_modules", ".git", ".next", "dist", "build", "__pycache__", - ".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache", - "target", "vendor", ".DS_Store", ".git", -]); - -const IGNORED_SUFFIXES = [".pyc"]; - -const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const; -type FileRequestType = typeof FILE_REQUEST_TYPES[number]; -const FILE_REQUEST_TYPE_SET = new Set(FILE_REQUEST_TYPES); -const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES; -const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024; -const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; -// Multipart boundaries and headers are not file bytes, but must be bounded too. -const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024; - -const EXT_TO_LANGUAGE: Record = { - ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", - mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", - go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift", - c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp", - html: "html", htm: "html", css: "css", scss: "css", less: "css", - json: "json", jsonl: "json", yaml: "yaml", yml: "yaml", - toml: "toml", xml: "xml", md: "markdown", mdx: "markdown", - sh: "bash", bash: "bash", zsh: "bash", fish: "bash", - sql: "sql", graphql: "graphql", gql: "graphql", - dockerfile: "dockerfile", tf: "hcl", hcl: "hcl", - env: "bash", gitignore: "bash", txt: "text", - pdf: "pdf", docx: "word", -}; - -function getLanguage(filePath: string): string { - const base = path.basename(filePath).toLowerCase(); - // Special full-name matches - if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile"; - if (base === ".env" || base.startsWith(".env.")) return "bash"; - if (base === "makefile" || base === "gnumakefile") return "makefile"; - const ext = base.split(".").pop() ?? ""; - return EXT_TO_LANGUAGE[ext] ?? "text"; -} - -function filePathFromSegments(segments: string[]): string { - const joined = segments.join("/"); - const slashJoined = normalizeSlashes(joined); - if (isWindowsAbsolutePath(slashJoined)) return slashJoined; - return "/" + joined.replace(/^\/+/, ""); -} - -function parseFileRequestType(value: string): FileRequestType | null { - return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null; -} - -async function getUploadDirectory(segments: string[]): Promise< - { directory: string } | { response: NextResponse } -> { - const directory = filePathFromSegments(segments); - const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(directory, allowedRoots)) { - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - - let stat: fs.Stats; - try { - stat = fs.statSync(directory); - } catch { - return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) }; - } - if (!stat.isDirectory()) { - return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) }; - } - - // A browsable directory can be a symlink. Resolve both sides before writes - // so a symlink inside an allowed root cannot redirect uploads outside it. - const realDirectory = fs.realpathSync(directory); - const realRoots = new Set(); - for (const root of allowedRoots) { - try { - realRoots.add(fs.realpathSync(root)); - } catch { - // Ignore stale session roots that no longer exist. - } - } - if (!isFilePathAllowed(realDirectory, realRoots)) { - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - - return { directory: realDirectory }; -} - -function parseUploadFileNames(value: unknown): string[] | null { - if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null; - return value; -} - -async function getWritableFile( - segments: string[], - sessionId: string | null, -): Promise<{ filePath: string } | { response: NextResponse }> { - const filePath = filePathFromSegments(segments); - const allowedRoots = await getAllowedFileRoots(); - const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); - const allowedBySessionReference = - !allowedByRoot && - await isFilePathReferencedBySession(filePath, sessionId); - if (!allowedByRoot && !allowedBySessionReference) { - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - - // The target must already exist as a regular file that resolves inside an - // allowed root. Resolving before the write prevents a symlink (possibly - // swapped in by a race) from redirecting the payload outside the roots, and - // the non-existing-path check above excludes files the session or current - // roots no longer own. - if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) { - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - - let stat: fs.Stats; - try { - stat = fs.lstatSync(filePath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return { response: NextResponse.json({ error: "Not found" }, { status: 404 }) }; - } - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - if (!stat.isFile() || stat.isSymbolicLink()) { - return { response: NextResponse.json({ error: "Not a writable file" }, { status: 403 }) }; - } - - return { filePath }; -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ path: string[] }> } -) { - if (!isApiRequestAllowed(request)) { - return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); - } - - try { - const { path: segments } = await params; - const type = request.nextUrl.searchParams.get("type") ?? "upload"; - const sessionId = request.nextUrl.searchParams.get("sessionId"); - - if (type === "write") { - const writable = await getWritableFile(segments, sessionId); - if ("response" in writable) return writable.response; - const { filePath } = writable; - - const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath); - if (readonlyFile) { - return NextResponse.json({ error: "This file type cannot be edited as text" }, { status: 400 }); - } - - const body = await request.json().catch(() => null) as { content?: unknown } | null; - if (!body || typeof body.content !== "string") { - return NextResponse.json({ error: "content must be a string" }, { status: 400 }); - } - const content = body.content; - const contentBytes = Buffer.byteLength(content, "utf-8"); - if (contentBytes > MAX_WRITE_BODY_BYTES) { - return NextResponse.json( - { error: "File too large to save (>256KB)" }, - { status: 413 }, - ); - } - - // Write to a temp file in the same directory then rename, so a crash or - // an error mid-write never leaves a truncated file behind. - const directory = path.dirname(filePath); - const tmpPath = path.join( - directory, - `.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`, - ); - try { - fs.writeFileSync(tmpPath, content, "utf-8"); - fs.renameSync(tmpPath, filePath); - } catch (writeError) { - try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } - return NextResponse.json( - { error: writeError instanceof Error ? writeError.message : String(writeError) }, - { status: 500 }, - ); - } - - const stat = fs.statSync(filePath); - return NextResponse.json({ size: stat.size }); - } - - const uploadDirectory = await getUploadDirectory(segments); - if ("response" in uploadDirectory) return uploadDirectory.response; - const { directory } = uploadDirectory; - - if (type === "upload-check") { - const body = await request.json().catch(() => null) as { fileNames?: unknown } | null; - const fileNames = parseUploadFileNames(body?.fileNames); - if (!fileNames) { - return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 }); - } - const validationError = validateUploadFileNames(fileNames); - if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }); - } - return NextResponse.json(inspectUploadTargets(directory, fileNames)); - } - - if (type !== "upload") { - return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 }); - } - - const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict")); - if (!strategy) { - return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 }); - } - - let formData: FormData; - try { - formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES); - } catch (error) { - if (error instanceof RequestBodyTooLargeError) { - return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 }); - } - throw error; - } - const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string"); - if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) { - return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 }); - } - if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) { - return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 }); - } - const fileNames = files.map((file) => file.name); - const validationError = validateUploadFileNames(fileNames); - if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }); - } - - const inspection = inspectUploadTargets(directory, fileNames); - if (strategy === "error" && inspection.conflicts.length > 0) { - return NextResponse.json({ - error: "One or more files already exist", - conflicts: inspection.conflicts, - nonReplaceable: inspection.nonReplaceable, - }, { status: 409 }); - } - - const conflictSet = new Set(inspection.conflicts); - const nonReplaceableSet = new Set(inspection.nonReplaceable); - const uploaded: string[] = []; - const skipped: string[] = []; - const errors: Array<{ name: string; error: string }> = []; - - for (const file of files) { - const destination = path.join(directory, file.name); - if (conflictSet.has(file.name) && strategy === "skip") { - skipped.push(file.name); - continue; - } - if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) { - errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" }); - continue; - } - - let bytes: Buffer; - try { - bytes = Buffer.from(await file.arrayBuffer()); - } catch (error) { - errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); - continue; - } - - if (conflictSet.has(file.name)) { - try { - fs.unlinkSync(destination); - } catch (error) { - errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); - continue; - } - } - - try { - fs.writeFileSync(destination, bytes, { flag: "wx" }); - uploaded.push(file.name); - } catch (error) { - errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); - } - } - - return NextResponse.json( - { uploaded, skipped, errors }, - { status: errors.length > 0 ? 207 : 200 }, - ); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); - } -} - -function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream { - const fileStream = fs.createReadStream(filePath, range); - let closed = false; - - return new ReadableStream({ - start(controller) { - fileStream.on("data", (chunk: Buffer) => { - if (closed) return; - try { - controller.enqueue(new Uint8Array(chunk)); - } catch { - closed = true; - fileStream.destroy(); - } - }); - fileStream.once("end", () => { - if (closed) return; - closed = true; - try { - controller.close(); - } catch { - // The browser may cancel media probes before the file stream ends. - } - }); - fileStream.once("error", (error) => { - if (closed) return; - closed = true; - try { - controller.error(error); - } catch { - // The response was already abandoned by the client. - } - }); - }, - cancel() { - closed = true; - fileStream.destroy(); - }, - }); -} - -function encodeHeaderValue(value: string): string { - return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => - `%${ch.charCodeAt(0).toString(16).toUpperCase()}` - ); -} - -function getContentDisposition(filePath: string, asDownload = false): string { - const disposition = asDownload ? "attachment" : "inline"; - const fileName = path.basename(filePath); - const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download"; - return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`; -} - -function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response { - const headers = { - "Content-Type": contentType, - "Cache-Control": "no-cache", - "Accept-Ranges": "bytes", - "Content-Disposition": getContentDisposition(filePath, asDownload), - }; - - if (!rangeHeader) { - return new Response(createFileBodyStream(filePath), { - headers: { - ...headers, - "Content-Length": String(stat.size), - }, - }); - } - - const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader); - if (!match) { - return new Response(null, { - status: 416, - headers: { - ...headers, - "Content-Range": `bytes */${stat.size}`, - }, - }); - } - - let start = match[1] ? Number(match[1]) : 0; - let end = match[2] ? Number(match[2]) : stat.size - 1; - if (!match[1] && match[2]) { - const suffixLength = Number(match[2]); - start = Math.max(stat.size - suffixLength, 0); - end = stat.size - 1; - } - - if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) { - return new Response(null, { - status: 416, - headers: { - ...headers, - "Content-Range": `bytes */${stat.size}`, - }, - }); - } - - end = Math.min(end, stat.size - 1); - const chunkSize = end - start + 1; - return new Response(createFileBodyStream(filePath, { start, end }), { - status: 206, - headers: { - ...headers, - "Content-Length": String(chunkSize), - "Content-Range": `bytes ${start}-${end}/${stat.size}`, - }, - }); -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string { - return ` - - - - - - - -
-
${escapeHtml(fileName)}
-${bodyHtml} -
- -`; -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ path: string[] }> } -) { - try { - const { path: segments } = await params; - const filePath = filePathFromSegments(segments); - const rawType = request.nextUrl.searchParams.get("type") ?? "list"; - const type = parseFileRequestType(rawType); - if (!type) { - return NextResponse.json({ error: "Invalid file request type" }, { status: 400 }); - } - const sessionId = request.nextUrl.searchParams.get("sessionId"); - - const allowedRoots = await getAllowedFileRoots(); - const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); - const allowedBySessionReference = - !allowedByRoot && - type !== "list" && - await isFilePathReferencedBySession(filePath, sessionId); - if (!allowedByRoot && !allowedBySessionReference) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - let stat: fs.Stats; - try { - stat = fs.statSync(filePath); - } catch { - return NextResponse.json({ error: "Not found" }, { status: 404 }); - } - - if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - if (type === "read") { - if (!stat.isFile()) { - return NextResponse.json({ error: "Not a file" }, { status: 400 }); - } - const imageMime = getImageMime(filePath); - if (imageMime) { - if (stat.size > IMAGE_PREVIEW_MAX_BYTES) { - return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 }); - } - return streamFile(filePath, stat, imageMime, request.headers.get("range")); - } - const audioMime = getAudioMime(filePath); - if (audioMime) { - return streamFile(filePath, stat, audioMime, request.headers.get("range")); - } - const documentMime = getDocumentMime(filePath); - if (documentMime) { - return streamFile(filePath, stat, documentMime, request.headers.get("range")); - } - if (stat.size > TEXT_PREVIEW_MAX_BYTES) { - return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 }); - } - const content = fs.readFileSync(filePath, "utf-8"); - const language = getLanguage(filePath); - return NextResponse.json({ content, language, size: stat.size }); - } - - if (type === "download") { - if (!stat.isFile()) { - return NextResponse.json({ error: "Not a file" }, { status: 400 }); - } - const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream"; - return streamFile(filePath, stat, mime, request.headers.get("range"), true); - } - - if (type === "meta") { - if (!stat.isFile()) { - return NextResponse.json({ error: "Not a file" }, { status: 400 }); - } - const imageMime = getImageMime(filePath); - const audioMime = getAudioMime(filePath); - const documentMime = getDocumentMime(filePath); - return NextResponse.json({ - size: stat.size, - language: getLanguage(filePath), - mime: imageMime || audioMime || documentMime || "text/plain", - previewKind: documentPreviewKind(filePath), - }); - } - - if (type === "preview") { - if (!stat.isFile()) { - return NextResponse.json({ error: "Not a file" }, { status: 400 }); - } - if (getFileExt(filePath) !== "docx") { - return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 }); - } - if (stat.size > DOCX_PREVIEW_MAX_BYTES) { - return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 }); - } - - const mammoth = await import("mammoth"); - const result = await mammoth.convertToHtml( - { path: filePath }, - { - externalFileAccess: false, - convertImage: mammoth.images.dataUri, - } - ); - const html = wrapDocxPreviewHtml(result.value, path.basename(filePath)); - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache", - "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - }, - }); - } - - if (type === "watch") { - if (!stat.isFile()) { - return NextResponse.json({ error: "Not a file" }, { status: 400 }); - } - let watcher: fs.FSWatcher | null = null; - let lastMtimeMs = stat.mtimeMs; - let lastSize = stat.size; - const stream = new ReadableStream({ - start(controller) { - const send = (eventName: string, data: Record) => { - const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; - try { - controller.enqueue(new TextEncoder().encode(payload)); - } catch { - // client disconnected - } - }; - // Send initial ping so client knows connection is live - send("connected", { filePath }); - try { - watcher = fs.watch(filePath, () => { - try { - const s = fs.statSync(filePath); - // Some platforms emit watch events for file reads/attribute - // access. Ignore those or the client's refresh read loops. - if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return; - lastMtimeMs = s.mtimeMs; - lastSize = s.size; - send("change", { mtime: s.mtime.toISOString(), size: s.size }); - } catch { - send("change", { mtime: new Date().toISOString(), size: 0 }); - } - }); - watcher.on("error", () => { - try { controller.close(); } catch { /* ignore */ } - }); - } catch { - send("error", { message: "Failed to watch file" }); - controller.close(); - } - }, - cancel() { - try { watcher?.close(); } catch { /* ignore */ } - }, - }); - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }, - }); - } - - // type === "list" - if (!stat.isDirectory()) { - return NextResponse.json({ error: "Not a directory" }, { status: 400 }); - } - - // Avoid per-entry stat calls for normal files and directories. Symlinks and - // filesystems without directory type information use the stat fallback. - const dirents = fs.readdirSync(filePath, { withFileTypes: true }); - const entries = dirents - .filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) - .flatMap((d) => { - const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name)); - return isDir === null - ? [] - : [{ name: d.name, isDir, size: 0, modified: "" }]; - }) - .sort((a, b) => { - // Dirs first, then files, both alphabetically - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - return NextResponse.json({ entries, path: filePath }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/git/diff/route.ts b/pi-web/app/api/git/diff/route.ts deleted file mode 100644 index e896d0d..0000000 --- a/pi-web/app/api/git/diff/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access"; -import { getGitFileDiff } from "@/lib/git-changes"; - -export async function GET(request: NextRequest) { - try { - const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; - const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? ""; - if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { - return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); - } - if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) { - return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 }); - } - - const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - // The cwd must resolve inside an allowed root. The file itself may no - // longer exist when Git reports it as deleted; getGitFileDiff verifies - // that the requested path belongs to this repository and its status. - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - return NextResponse.json(await getGitFileDiff(cwd, filePath)); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/git/status/route.ts b/pi-web/app/api/git/status/route.ts deleted file mode 100644 index ead1792..0000000 --- a/pi-web/app/api/git/status/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import fs from "fs"; -import { NextRequest, NextResponse } from "next/server"; -import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access"; -import { getGitStatus } from "@/lib/git-changes"; - -export async function GET(request: NextRequest) { - try { - const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? ""; - if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { - return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); - } - - const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - let stat: fs.Stats; - try { - stat = fs.statSync(cwd); - } catch { - return NextResponse.json({ error: "Directory not found" }, { status: 404 }); - } - if (!stat.isDirectory()) { - return NextResponse.json({ error: "Not a directory" }, { status: 400 }); - } - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - return NextResponse.json(await getGitStatus(cwd)); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/home/route.ts b/pi-web/app/api/home/route.ts deleted file mode 100644 index a21f3d6..0000000 --- a/pi-web/app/api/home/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NextResponse } from "next/server"; -import { homedir } from "os"; - -export async function GET() { - return NextResponse.json({ home: homedir() }); -} diff --git a/pi-web/app/api/mcp/route.ts b/pi-web/app/api/mcp/route.ts deleted file mode 100644 index 2225f74..0000000 --- a/pi-web/app/api/mcp/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NextResponse } from "next/server"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; - -export const dynamic = "force-dynamic"; - -function getMcpPath(): string { - return join(getAgentDir(), "mcp.json"); -} - -interface McpConfigFile { - mcpServers: Record>; - settings?: Record; - imports?: string[]; -} - -function readMcpJson(): McpConfigFile { - const path = getMcpPath(); - if (!existsSync(path)) return { mcpServers: {} }; - try { - const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile; - if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") { - return { mcpServers: {} }; - } - return parsed; - } catch { - return { mcpServers: {} }; - } -} - -function writeMcpJson(data: McpConfigFile): void { - const path = getMcpPath(); - const dir = dirname(path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); -} - -export function GET() { - return NextResponse.json({ config: readMcpJson(), path: getMcpPath() }); -} - -export async function PUT(req: Request) { - try { - const body = (await req.json()) as McpConfigFile; - if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) { - return NextResponse.json({ error: "mcpServers must be an object" }, { status: 400 }); - } - // 只保留已知顶层字段,避免写入无关键 - const out: McpConfigFile = { mcpServers: body.mcpServers }; - if (body.settings && typeof body.settings === "object") out.settings = body.settings; - if (Array.isArray(body.imports)) out.imports = body.imports; - writeMcpJson(out); - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/models-config/catalog/route.ts b/pi-web/app/api/models-config/catalog/route.ts deleted file mode 100644 index f0e7cbf..0000000 --- a/pi-web/app/api/models-config/catalog/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { NextResponse } from "next/server"; -import { - flattenModelsDevCatalog, - recommendModelCatalogPreset, - searchModelCatalog, - type ModelCatalogEntry, -} from "@/lib/model-catalog"; - -export const dynamic = "force-dynamic"; - -const MODELS_DEV_URL = "https://models.dev/api.json"; -const CATALOG_TTL_MS = 60 * 60 * 1000; -const FETCH_TIMEOUT_MS = 15_000; - -interface CatalogCache { - entries: ModelCatalogEntry[]; - expiresAt: number; - inFlight?: Promise; -} - -declare global { - var __piModelsDevCatalogCache: CatalogCache | undefined; -} - -function getCache(): CatalogCache { - return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 }; -} - -async function fetchCatalog(): Promise { - const response = await fetch(MODELS_DEV_URL, { - cache: "no-store", - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`); - const entries = flattenModelsDevCatalog(await response.json()); - if (entries.length === 0) throw new Error("models.dev returned an empty catalog"); - return entries; -} - -async function loadCatalog(): Promise { - const cache = getCache(); - if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries; - if (!cache.inFlight) { - cache.inFlight = fetchCatalog().then((entries) => { - cache.entries = entries; - cache.expiresAt = Date.now() + CATALOG_TTL_MS; - return entries; - }).finally(() => { - cache.inFlight = undefined; - }); - } - - try { - return await cache.inFlight; - } catch (error) { - if (cache.entries.length > 0) return cache.entries; - throw error; - } -} - -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const query = (searchParams.get("q") ?? "").slice(0, 120); - const provider = (searchParams.get("provider") ?? "").slice(0, 120); - const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500); - const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10); - const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50; - - try { - const entries = await loadCatalog(); - const models = searchModelCatalog(entries, query, provider, limit); - const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl); - return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL }); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 }); - } -} diff --git a/pi-web/app/api/models-config/discover/route.ts b/pi-web/app/api/models-config/discover/route.ts deleted file mode 100644 index 0302aa0..0000000 --- a/pi-web/app/api/models-config/discover/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NextResponse } from "next/server"; -import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth"; -import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery"; - -export const dynamic = "force-dynamic"; - -const DISCOVERY_TIMEOUT_MS = 20_000; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasHeader(headers: Headers, name: string): boolean { - return headers.has(name); -} - -function buildHeaders(api: string, apiKey: string | undefined, configured: Record): Headers { - const headers = new Headers(configured); - if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json"); - if (!apiKey) return headers; - - if (api === "anthropic-messages") { - if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey); - if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01"); - } else if (api === "google-generative-ai") { - if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey); - } else if (!hasHeader(headers, "authorization")) { - headers.set("Authorization", `Bearer ${apiKey}`); - } - return headers; -} - -export async function POST(req: Request) { - try { - const body = await req.json() as { providerName?: unknown; provider?: unknown }; - const providerName = typeof body.providerName === "string" ? body.providerName.trim() : ""; - if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 }); - if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 }); - - const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : ""; - if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 }); - const api = typeof body.provider.api === "string" && body.provider.api - ? body.provider.api - : "openai-completions"; - - let endpoint: URL; - try { - endpoint = buildModelsListUrl(baseUrl, api); - } catch { - return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 }); - } - - const auth = await resolveModelDiscoveryAuth(providerName, body.provider); - if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) { - return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 }); - } - - const response = await fetch(endpoint, { - cache: "no-store", - headers: buildHeaders(api, auth.apiKey, auth.headers), - signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), - }); - const responseText = await response.text(); - if (!response.ok) { - return NextResponse.json({ - error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`, - status: response.status, - }, { status: 502 }); - } - - let payload: unknown; - try { - payload = JSON.parse(responseText); - } catch { - return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 }); - } - const models = parseDiscoveredModels(payload); - if (models.length === 0) { - return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 }); - } - - return NextResponse.json({ models, endpoint: endpoint.toString() }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500; - return NextResponse.json({ error: message }, { status }); - } -} diff --git a/pi-web/app/api/models-config/route.ts b/pi-web/app/api/models-config/route.ts deleted file mode 100644 index b2a08e8..0000000 --- a/pi-web/app/api/models-config/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; -import { join, dirname } from "path"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { invalidateModelsCache } from "@/lib/models-cache"; - -export const dynamic = "force-dynamic"; - -function getModelsPath(): string { - return join(getAgentDir(), "models.json"); -} - -function readModelsJson(): Record { - const path = getModelsPath(); - if (!existsSync(path)) return { providers: {} }; - try { - return JSON.parse(readFileSync(path, "utf8")) as Record; - } catch { - return { providers: {} }; - } -} - -function writeModelsJson(data: Record): void { - const path = getModelsPath(); - const dir = dirname(path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(path, JSON.stringify(data, null, 2), "utf8"); -} - -export async function GET() { - return NextResponse.json(readModelsJson()); -} - -export async function PUT(req: Request) { - try { - const body = await req.json() as Record; - writeModelsJson(body); - invalidateModelsCache(); - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/models-config/test/route.ts b/pi-web/app/api/models-config/test/route.ts deleted file mode 100644 index 8c74afa..0000000 --- a/pi-web/app/api/models-config/test/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { NextResponse } from "next/server"; -import { mkdtempSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat"; -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; - -export const dynamic = "force-dynamic"; - -const TEST_TIMEOUT_MS = 20_000; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function getAssistantText(message: AssistantMessage): string { - return message.content - .filter((block) => block.type === "text") - .map((block) => block.text) - .join(""); -} - -export async function POST(req: Request) { - if (!isApiRequestAllowed(req)) { - return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 }); - } - if (!hasJsonContentType(req)) { - return NextResponse.json( - { ok: false, error: "Content-Type must be application/json" }, - { status: 415 }, - ); - } - - let tempDir: string | undefined; - - try { - const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown }; - const providerName = typeof body.providerName === "string" ? body.providerName.trim() : ""; - if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 }); - if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 }); - if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 }); - - const modelId = typeof body.model.id === "string" ? body.model.id.trim() : ""; - if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 }); - - tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-")); - const modelsPath = join(tempDir, "models.json"); - writeFileSync(modelsPath, JSON.stringify({ - providers: { - [providerName]: { - ...body.provider, - models: [{ ...body.model, id: modelId }], - }, - }, - }, null, 2), "utf8"); - - const modelRuntime = await ModelRuntime.create({ modelsPath }); - const loadError = modelRuntime.getError(); - if (loadError) return NextResponse.json({ ok: false, error: loadError }); - - const model = modelRuntime.getModel(providerName, modelId); - if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` }); - - const resolved = await modelRuntime.getAuth(model); - if (!resolved?.auth.apiKey) { - return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` }); - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); - let status: number | undefined; - const startedAt = Date.now(); - - try { - const message = await completeSimple(model, { - messages: [{ - role: "user", - content: "Reply with OK only.", - timestamp: Date.now(), - }], - }, { - apiKey: resolved.auth.apiKey, - headers: resolved.auth.headers, - maxTokens: 16, - timeoutMs: TEST_TIMEOUT_MS, - maxRetries: 0, - cacheRetention: "none", - signal: controller.signal, - onResponse: (response) => { status = response.status; }, - }); - - const latencyMs = Date.now() - startedAt; - if (message.stopReason === "error" || message.stopReason === "aborted") { - return NextResponse.json({ - ok: false, - error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"), - latencyMs, - status, - }); - } - - return NextResponse.json({ - ok: true, - latencyMs, - status, - responseText: getAssistantText(message).slice(0, 300), - }); - } finally { - clearTimeout(timeout); - } - } catch (error) { - return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 }); - } finally { - if (tempDir) rmSync(tempDir, { recursive: true, force: true }); - } -} diff --git a/pi-web/app/api/models/route.ts b/pi-web/app/api/models/route.ts deleted file mode 100644 index 46ad72d..0000000 --- a/pi-web/app/api/models/route.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { stat } from "fs/promises"; -import { resolve } from "path"; -import { - createAgentSessionServices, - getAgentDir, - type SettingsManager, -} from "@earendil-works/pi-coding-agent"; -import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; -import { - loadModelsWithCache, - withModelRuntimeError, - type ModelsData, -} from "@/lib/models-cache"; -import { - getAllowedFileRoots, - isExistingFilePathAllowed, -} from "@/lib/file-access"; -import { projectTrustReloadOptions } from "@/lib/project-trust"; -import { - createAppSettingsManager, - getAppResourceLoaderOptions, -} from "@/lib/app-runtime"; - -export const dynamic = "force-dynamic"; - -const modelNameCollator = new Intl.Collator(undefined, { - numeric: true, - sensitivity: "base", -}); - -function compareModelEntries( - a: { id: string; name: string; provider: string }, - b: { id: string; name: string; provider: string }, -): number { - return ( - modelNameCollator.compare(a.name || a.id, b.name || b.id) || - modelNameCollator.compare(a.provider, b.provider) || - modelNameCollator.compare(a.id, b.id) - ); -} - -const THINKING_SUFFIXES = new Set([ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -]); - -function stripThinkingSuffix(modelRef: string): string { - const trimmed = modelRef.trim(); - const colonIndex = trimmed.lastIndexOf(":"); - if (colonIndex === -1) return trimmed; - const suffix = trimmed.substring(colonIndex + 1); - return THINKING_SUFFIXES.has(suffix) - ? trimmed.substring(0, colonIndex) - : trimmed; -} - -function filterByExactEnabledModels( - available: readonly T[], - enabledModels: string[] | undefined, -): readonly T[] { - if (!enabledModels || enabledModels.length === 0) return available; - - const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean)); - const visible = available.filter( - (m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id), - ); - return visible.length > 0 ? visible : available; -} - -async function loadModels(cwd: string): Promise { - const nameMap = new Map(); - let modelList: { id: string; name: string; provider: string }[] = []; - let defaultModel: { provider: string; modelId: string } | null = null; - const thinkingLevels: Record = {}; - const thinkingLevelMaps: Record> = {}; - - const agentDir = getAgentDir(); - // Gate untrusted project extensions: enumerating models still imports and - // runs a repository's .pi/extensions factories, so honor project trust here - // too (see lib/project-trust.ts, #236). - const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir); - const services = await createAgentSessionServices({ - cwd, - agentDir, - settingsManager: createAppSettingsManager(cwd, agentDir), - resourceLoaderOptions: getAppResourceLoaderOptions(), - ...(trustReloadOptions - ? { resourceLoaderReloadOptions: trustReloadOptions } - : {}), - }); - const available = await services.modelRuntime.getAvailable(); - const modelError = services.modelRuntime.getError(); - const settings: SettingsManager = services.settingsManager; - const enabledModels = settings.getEnabledModels(); - const visible = filterByExactEnabledModels(available, enabledModels); - modelList = visible - .map( - (m: { - id: string; - name: string; - provider: string; - serviceTier?: string; - }) => ({ - id: m.id, - name: m.name, - provider: m.provider, - serviceTier: m.serviceTier, - }), - ) - .sort(compareModelEntries); - for (const m of visible) { - const key = `${m.provider}:${m.id}`; - nameMap.set(key, m.name); - thinkingLevels[key] = getSupportedThinkingLevels(m); - if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap; - } - - const provider = settings.getDefaultProvider(); - const modelId = settings.getDefaultModel(); - if ( - provider && - modelId && - visible.some((m) => m.provider === provider && m.id === modelId) - ) { - defaultModel = { provider, modelId }; - } - - return withModelRuntimeError( - { - models: Object.fromEntries(nameMap), - modelList, - defaultModel, - thinkingLevels, - thinkingLevelMaps, - }, - modelError, - ); -} - -const EMPTY_MODELS: ModelsData = { - models: {}, - modelList: [], - defaultModel: null, - thinkingLevels: {}, - thinkingLevelMaps: {}, -}; - -export async function GET(req: Request) { - const requestedCwd = - new URL(req.url).searchParams.get("cwd") || process.cwd(); - const cwd = resolve(requestedCwd); - - let cwdStat; - try { - cwdStat = await stat(cwd); - } catch { - return Response.json( - { error: `Directory does not exist: ${cwd}` }, - { status: 400 }, - ); - } - if (!cwdStat.isDirectory()) { - return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 }); - } - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - - try { - return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd))); - } catch { - return Response.json(EMPTY_MODELS); - } -} diff --git a/pi-web/app/api/plugins/route.ts b/pi-web/app/api/plugins/route.ts deleted file mode 100644 index f1f5107..0000000 --- a/pi-web/app/api/plugins/route.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { NextResponse } from "next/server"; -import { existsSync, readFileSync, statSync } from "fs"; -import { basename, dirname, extname, join, relative, resolve } from "path"; -import { - DefaultPackageManager, - getAgentDir, - SettingsManager, - type PackageSource, - type ResolvedPaths, - type ResolvedResource, -} from "@earendil-works/pi-coding-agent"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; -import { getProjectTrustStatus } from "@/lib/project-trust"; -import { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime"; -import type { - PluginDiagnostic, - PluginPackageInfo, - PluginResourceCounts, - PluginResourceInfo, - PluginResourceKind, - PluginScope, - PluginsResponse, -} from "@/lib/api-types"; - -export const dynamic = "force-dynamic"; - -type PluginAction = "install" | "remove" | "update" | "disable" | "enable"; - -function emptyCounts(): PluginResourceCounts { - return { extensions: 0, skills: 0, prompts: 0, themes: 0 }; -} - -function toPluginScope(scope: string): PluginScope { - return scope === "project" ? "project" : "global"; -} - -function keyFor(source: string, scope: PluginScope): string { - return `${scope}\0${source}`; -} - -function getPackageSource(entry: PackageSource): string { - return typeof entry === "string" ? entry : entry.source; -} - -function isDisabledPackage(entry: PackageSource): boolean { - if (typeof entry === "string") return false; - return ( - Array.isArray(entry.extensions) && entry.extensions.length === 0 && - Array.isArray(entry.skills) && entry.skills.length === 0 && - Array.isArray(entry.prompts) && entry.prompts.length === 0 && - Array.isArray(entry.themes) && entry.themes.length === 0 - ); -} - -function getDisabledPackages(settingsManager: SettingsManager): Map { - const disabled = new Map(); - for (const entry of settingsManager.getGlobalSettings().packages ?? []) { - disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry)); - } - for (const entry of settingsManager.getProjectSettings().packages ?? []) { - disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry)); - } - return disabled; -} - -function setPackageDisabled( - settingsManager: SettingsManager, - source: string, - scope: PluginScope, - disabled: boolean, -): boolean { - const current = scope === "project" - ? settingsManager.getProjectSettings().packages ?? [] - : settingsManager.getGlobalSettings().packages ?? []; - let changed = false; - const next = current.map((entry): PackageSource => { - if (getPackageSource(entry) !== source) return entry; - changed = true; - if (disabled) { - return { - ...(typeof entry === "string" ? { source: entry } : entry), - extensions: [], - skills: [], - prompts: [], - themes: [], - }; - } - return getPackageSource(entry); - }); - if (!changed) return false; - if (scope === "project") settingsManager.setProjectPackages(next); - else settingsManager.setPackages(next); - return true; -} - -function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void { - counts[kind] += 1; -} - -function getResourceName(path: string, kind: PluginResourceKind): string { - const file = basename(path); - const ext = extname(file); - if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path)); - if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) { - if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path)); - return file.slice(0, -ext.length); - } - return file; -} - -function getRelativePath(resource: ResolvedResource): string { - const baseDir = resource.metadata.baseDir; - if (!baseDir) return resource.path; - const rel = relative(baseDir, resource.path); - return rel && !rel.startsWith("..") ? rel : resource.path; -} - -function getConfiguredVersion(source: string): string | undefined { - const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined; - if (npmSpec) { - const lastAt = npmSpec.lastIndexOf("@"); - const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0; - if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined; - return undefined; - } - - if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) { - const lastAt = source.lastIndexOf("@"); - const lastSlash = source.lastIndexOf("/"); - const lastColon = source.lastIndexOf(":"); - if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined; - } - return undefined; -} - -function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } { - if (!installedPath) return {}; - try { - const stats = statSync(installedPath); - const packageJsonPath = stats.isDirectory() - ? join(installedPath, "package.json") - : join(dirname(installedPath), "package.json"); - if (!existsSync(packageJsonPath)) return {}; - const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { - name?: unknown; - version?: unknown; - }; - return { - packageName: typeof parsed.name === "string" ? parsed.name : undefined, - version: typeof parsed.version === "string" ? parsed.version : undefined, - }; - } catch { - return {}; - } -} - -function collectResource( - resource: ResolvedResource, - kind: keyof PluginResourceCounts, - countsByPackage: Map, - resourcesByPackage: Map, - totals: PluginResourceCounts, -): void { - if (!isManagedPath(resource.path)) return; - if (!resource.enabled || resource.metadata.origin !== "package") return; - const source = resource.metadata.source; - const scope = toPluginScope(resource.metadata.scope); - const key = keyFor(source, scope); - const counts = countsByPackage.get(key) ?? emptyCounts(); - addCount(counts, kind); - addCount(totals, kind); - countsByPackage.set(key, counts); - const resources = resourcesByPackage.get(key) ?? []; - const resourceKind = kind === "extensions" - ? "extension" - : kind === "skills" - ? "skill" - : kind === "prompts" - ? "prompt" - : "theme"; - resources.push({ - kind: resourceKind, - name: getResourceName(resource.path, resourceKind), - path: resource.path, - relativePath: getRelativePath(resource), - }); - resourcesByPackage.set(key, resources); -} - -function collectResources(paths: ResolvedPaths): { - countsByPackage: Map; - resourcesByPackage: Map; - totals: PluginResourceCounts; -} { - const countsByPackage = new Map(); - const resourcesByPackage = new Map(); - const totals = emptyCounts(); - for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals); - for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals); - for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals); - for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals); - return { countsByPackage, resourcesByPackage, totals }; -} - -async function readPlugins(cwd: string): Promise { - const agentDir = getAgentDir(); - const projectTrust = getProjectTrustStatus(cwd, agentDir); - const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted); - const packageManager = new DefaultPackageManager({ - cwd, - agentDir, - settingsManager, - }); - - const diagnostics: PluginDiagnostic[] = []; - let countsByPackage = new Map(); - let resourcesByPackage = new Map(); - let totals = emptyCounts(); - const disabledByPackage = getDisabledPackages(settingsManager); - - try { - const resolved = await packageManager.resolve(async (source) => { - diagnostics.push({ - type: "warning", - source, - message: "Package is configured but not installed yet.", - }); - return "skip"; - }); - ({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved)); - } catch (error) { - diagnostics.push({ - type: "error", - message: error instanceof Error ? error.message : String(error), - }); - } - - const packages = packageManager.listConfiguredPackages().map((pkg) => { - const scope = toPluginScope(pkg.scope); - const key = keyFor(pkg.source, scope); - const disabled = disabledByPackage.get(key) ?? false; - const counts = countsByPackage.get(key) ?? emptyCounts(); - const resources = resourcesByPackage.get(key) ?? []; - const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes; - const packageMetadata = readPackageMetadata(pkg.installedPath); - if (!pkg.installedPath) { - diagnostics.push({ - type: "warning", - source: pkg.source, - message: "Configured package path was not found.", - }); - } - return { - source: pkg.source, - scope, - filtered: pkg.filtered, - disabled, - installedPath: pkg.installedPath, - packageName: packageMetadata.packageName, - version: packageMetadata.version, - configuredVersion: getConfiguredVersion(pkg.source), - counts, - resources, - status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing", - } satisfies PluginPackageInfo; - }); - - return { - packages, - totals, - diagnostics, - projectResourcesLoaded: projectTrust.trusted, - }; -} - -function readScope(scope: unknown): PluginScope { - if (isManagedRuntime()) return "global"; - return scope === "project" ? "project" : "global"; -} - -function isRemotePackageSource(source: string): boolean { - return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source); -} - -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const cwd = searchParams.get("cwd"); - if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); - - try { - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - return NextResponse.json(await readPlugins(cwd)); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// POST /api/plugins body: { action, source?, scope?, cwd } -export async function POST(req: Request) { - if (!isApiRequestAllowed(req)) { - return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); - } - if (!hasJsonContentType(req)) { - return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); - } - - try { - const body = await req.json() as { - action?: PluginAction; - source?: string; - scope?: PluginScope; - cwd?: string; - }; - if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); - if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 }); - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const agentDir = getAgentDir(); - const projectTrust = getProjectTrustStatus(body.cwd, agentDir); - const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted); - const scope = readScope(body.scope); - if (scope === "project" && !projectTrust.trusted) { - return NextResponse.json( - { error: "Project resources must be trusted before modifying project plugins" }, - { status: 403 }, - ); - } - const packageManager = new DefaultPackageManager({ - cwd: body.cwd, - agentDir, - settingsManager, - }); - const source = body.source?.trim(); - const local = scope === "project"; - - if ( - source && - isManagedRuntime() && - !isRemotePackageSource(source) && - !isManagedPath(resolve(body.cwd, source)) - ) { - return NextResponse.json( - { error: "Local plugins must be stored inside the integrated application directory" }, - { status: 400 }, - ); - } - - if (body.action === "install") { - if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); - await packageManager.installAndPersist(source, { local }); - } else if (body.action === "remove") { - if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); - await packageManager.removeAndPersist(source, { local }); - } else if (body.action === "update") { - await packageManager.update(source); - } else if (body.action === "disable") { - if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); - setPackageDisabled(settingsManager, source, scope, true); - await settingsManager.flush(); - } else if (body.action === "enable") { - if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); - setPackageDisabled(settingsManager, source, scope, false); - await settingsManager.flush(); - } else { - return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 }); - } - - return NextResponse.json(await readPlugins(body.cwd)); - } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/project-trust/route.ts b/pi-web/app/api/project-trust/route.ts deleted file mode 100644 index c7add83..0000000 --- a/pi-web/app/api/project-trust/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { stat } from "fs/promises"; -import { resolve } from "path"; -import { NextResponse } from "next/server"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { invalidateModelsCache } from "@/lib/models-cache"; -import { getProjectTrustStatus, trustProject } from "@/lib/project-trust"; -import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "@/lib/rpc-manager"; - -export const dynamic = "force-dynamic"; - -async function validateCwd(value: unknown): Promise< - { cwd: string } | { response: NextResponse } -> { - if (typeof value !== "string" || !value.trim()) { - return { response: NextResponse.json({ error: "cwd required" }, { status: 400 }) }; - } - - const cwd = resolve(value); - try { - if (!(await stat(cwd)).isDirectory()) { - return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) }; - } - } catch { - return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) }; - } - - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; - } - return { cwd }; -} - -export async function GET(req: Request) { - const result = await validateCwd(new URL(req.url).searchParams.get("cwd")); - if ("response" in result) return result.response; - return NextResponse.json(getProjectTrustStatus(result.cwd, getAgentDir())); -} - -export async function POST(req: Request) { - try { - const body = await req.json() as { cwd?: unknown }; - const result = await validateCwd(body.cwd); - if ("response" in result) return result.response; - - const agentDir = getAgentDir(); - const current = getProjectTrustStatus(result.cwd, agentDir); - if (!current.requiresTrust) { - return NextResponse.json({ error: "This project has no resources that require trust" }, { status: 409 }); - } - if (hasBusyRpcSessionForCwd(result.cwd)) { - return NextResponse.json({ error: "Wait for the active session to finish before trusting this project" }, { status: 409 }); - } - - const status = trustProject(result.cwd, agentDir); - invalidateModelsCache(); - destroyRpcSessionsForCwd(result.cwd); - return NextResponse.json(status); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500 }, - ); - } -} diff --git a/pi-web/app/api/runtime/route.ts b/pi-web/app/api/runtime/route.ts deleted file mode 100644 index 99686ba..0000000 --- a/pi-web/app/api/runtime/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { - getManagedRuntimePaths, - isManagedRuntime, -} from "@/lib/app-runtime"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - if (!isManagedRuntime()) { - return Response.json({ managed: false, agentDir: getAgentDir() }); - } - - const paths = getManagedRuntimePaths(); - return Response.json({ - managed: true, - appRoot: paths.appRoot, - dataDir: paths.dataDir, - agentDir: paths.agentDir, - resourcesDir: paths.resourcesDir, - skillRoots: paths.managedSkillRoots, - }); -} diff --git a/pi-web/app/api/sessions/[id]/auto-name/route.ts b/pi-web/app/api/sessions/[id]/auto-name/route.ts deleted file mode 100644 index db6edc0..0000000 --- a/pi-web/app/api/sessions/[id]/auto-name/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextResponse } from "next/server"; -import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent"; -import { generateSessionTitle } from "@/lib/session-title"; -import { getRpcSession, startRpcSession } from "@/lib/rpc-manager"; -import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader"; - -export async function POST( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params; - - try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); - const existing = getRpcSession(id); - const { session } = existing?.isAlive() - ? { session: existing } - : await startRpcSession(id, filePath, cwd); - - // globalThis keeps wrappers alive across dev hot reloads; older instances - // may predate waitUntilReady(), but those have already completed startup. - await session.waitUntilReady?.(); - const result = await generateSessionTitle(session.inner as unknown as AgentSession); - - if (!session.isAlive()) { - return NextResponse.json( - { error: "The session was closed while its title was being generated. Please try again." }, - { status: 409 }, - ); - } - - session.inner.setSessionName(result.title); - invalidateSessionListCache(); - return NextResponse.json({ title: result.title, usage: result.usage ?? null }); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500 }, - ); - } -} diff --git a/pi-web/app/api/sessions/[id]/context/route.ts b/pi-web/app/api/sessions/[id]/context/route.ts deleted file mode 100644 index cadd03f..0000000 --- a/pi-web/app/api/sessions/[id]/context/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from "next/server"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; -import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader"; - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const url = new URL(req.url); - const leafId = url.searchParams.get("leafId") ?? undefined; - const deferThinking = url.searchParams.has("deferThinking"); - const deferToolResultImages = url.searchParams.has("deferMedia"); - - try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const sm = SessionManager.open(filePath); - const context = buildSessionContext(sm.getEntries() as never, leafId, { - deferThinking, - deferToolResultImages, - }); - - return NextResponse.json({ context }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts b/pi-web/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts deleted file mode 100644 index 4d7db57..0000000 --- a/pi-web/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader"; - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string; entryId: string }> }, -) { - const { id, entryId } = await params; - const blockIndexParam = new URL(req.url).searchParams.get("blockIndex"); - const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam); - if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) { - return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 }); - } - - try { - const filePath = await resolveSessionPath(id); - if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 }); - - // SessionManager-backed parsing preserves the SDK's malformed-line tolerance. - const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId); - if (!entry || entry.type !== "message" || entry.message.role !== "assistant") { - return NextResponse.json({ error: "Assistant message not found" }, { status: 404 }); - } - - const block = entry.message.content[blockIndex]; - if (!block || block.type !== "thinking") { - return NextResponse.json({ error: "Thinking block not found" }, { status: 404 }); - } - - return NextResponse.json({ thinking: block.thinking }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/sessions/[id]/export/route.ts b/pi-web/app/api/sessions/[id]/export/route.ts deleted file mode 100644 index bda67e9..0000000 --- a/pi-web/app/api/sessions/[id]/export/route.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { randomUUID } from "crypto"; -import { execFile } from "child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { basename, dirname, join } from "path"; -import { promisify } from "util"; -import { fileURLToPath, pathToFileURL } from "url"; -import { NextResponse } from "next/server"; -import { resolveSessionPath } from "@/lib/session-reader"; - -const execFileAsync = promisify(execFile); - -export const runtime = "nodejs"; - -type PiCodingAgentModule = { - getPackageDir: () => string; -}; - -type ExportHtmlModule = { - exportFromFile: (inputPath: string, outputPath: string) => Promise; -}; - -async function getPiPackageDir(): Promise { - try { - const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule; - return getPackageDir(); - } catch { - return null; - } -} - -function encodeHeaderValue(value: string): string { - return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => - `%${ch.charCodeAt(0).toString(16).toUpperCase()}` - ); -} - -function getContentDisposition(fileName: string, inline: boolean): string { - const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html"; - const disposition = inline ? "inline" : "attachment"; - return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`; -} - -async function getPiCliPath(): Promise { - const candidates = new Set(); - const packageDir = await getPiPackageDir(); - - if (packageDir) { - candidates.add(join(packageDir, "dist", "cli.js")); - } - - try { - const resolver = (import.meta as ImportMeta & { - resolve?: (specifier: string) => string | Promise; - }).resolve; - if (typeof resolver === "function") { - const indexUrl = await resolver("@earendil-works/pi-coding-agent"); - candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js")); - } - } catch { - // Next.js production bundles can strip import.meta.resolve. - } - - candidates.add( - join( - process.cwd(), - "node_modules", - "@earendil-works", - "pi-coding-agent", - "dist", - "cli.js" - ) - ); - - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} - -/** - * Patch the exported HTML to fix recursive functions that overflow - * the call stack on deep linear session trees (e.g., 5000+ entries). - * - * ## Root Cause - * pi-coding-agent's template.js uses recursive helpers to render and - * navigate the session tree in the exported HTML: - * - * 1. sortChildren(node) — recursively sorts children of every node. - * Calls itself via node.children.forEach(sortChildren). - * On a 5527-entry linear chain (no branches), this recurses 5527 - * levels deep → stack overflow. - * - * 2. mapNodes(node) — recursively indexes tree nodes the first time - * a tree item is clicked. Same depth -> same overflow. - * - * 3. markActive(node) — recursively marks nodes on the active path. - * Calls itself via markActive(child) for each child. - * Same depth → same overflow. - * - * Both functions are inlined in the HTML by pi-coding-agent at export - * time. We cannot modify template.js directly (it's in node_modules - * and would be overwritten on npm install). Instead, we patch the - * generated HTML string before returning it to the client. - * - * ## Fix - * Replace each recursive function with an iterative equivalent: - * - * sortChildren → explicit stack (DFS pre-order, push children in - * reverse to maintain order) - * mapNodes → explicit stack (DFS pre-order) - * markActive → two-stack post-order (stack1 for traversal, - * stack2 for processing children before parent) - * - * ## Line Ending Normalization - * This file (route.ts) uses CRLF (Windows), while template.js uses LF - * (Unix). The template strings in the backtick literals inherit the - * file's CRLF line endings. At runtime, readFileSync() also returns - * CRLF on Windows. We normalize everything to LF before matching. - * - * The helper `n(s)` strips \r\n → \n on both the HTML and the - * replacement strings, ensuring cross-platform matching. - */ -function patchExportHtml(html: string): string { - // Normalize line endings: route.ts is CRLF, template.js is LF. - // Without this, the replace() below would fail on Windows. - const n = (s: string) => s.replace(/\r\n/g, "\n"); - html = n(html); - - const replaceRequired = (source: string, name: string, search: string, replacement: string) => { - const normalizedSearch = n(search); - const normalizedReplacement = n(replacement); - const matches = source.split(normalizedSearch).length - 1; - if (matches !== 1) { - throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`); - } - return source.replace(normalizedSearch, normalizedReplacement); - }; - - html = replaceRequired( - html, - "sortChildren", - ` function sortChildren(node) { - node.children.sort((a, b) => - new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() - ); - node.children.forEach(sortChildren); - }`, - ` function sortChildren(root) { - const stack = [root]; - while (stack.length) { - const node = stack.pop(); - node.children.sort((a, b) => - new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() - ); - for (let i = node.children.length - 1; i >= 0; i--) { - stack.push(node.children[i]); - } - } - }` - ); - - html = replaceRequired( - html, - "mapNodes", - ` function mapNodes(node) { - treeNodeMap.set(node.entry.id, node); - node.children.forEach(mapNodes); - } - tree.forEach(mapNodes);`, - ` const stack = [...tree].reverse(); - while (stack.length) { - const node = stack.pop(); - treeNodeMap.set(node.entry.id, node); - for (let i = node.children.length - 1; i >= 0; i--) { - stack.push(node.children[i]); - } - }` - ); - - html = replaceRequired( - html, - "markActive", - ` function markActive(node) { - let has = activePathIds.has(node.entry.id); - for (const child of node.children) { - if (markActive(child)) has = true; - } - containsActive.set(node, has); - return has; - }`, - ` function markActive(root) { - // Post-order traversal using two stacks - const stack1 = [root]; - const stack2 = []; - while (stack1.length) { - const node = stack1.pop(); - stack2.push(node); - for (const child of node.children) { - stack1.push(child); - } - } - while (stack2.length) { - const node = stack2.pop(); - let has = activePathIds.has(node.entry.id); - for (const child of node.children) { - if (containsActive.get(child)) has = true; - } - containsActive.set(node, has); - } - }` - ); - - return html; -} - -async function exportSession(filePath: string, outputPath: string): Promise { - const cliPath = await getPiCliPath(); - if (cliPath) { - await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], { - cwd: process.cwd(), - timeout: 30_000, - env: { - ...process.env, - PI_OFFLINE: "1", - PI_SKIP_VERSION_CHECK: "1", - }, - maxBuffer: 1024 * 1024, - }); - return; - } - - const packageDir = await getPiPackageDir(); - if (!packageDir) throw new Error("pi CLI not found"); - - const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href; - const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule; - await exportFromFile(filePath, outputPath); -} - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const inline = new URL(req.url).searchParams.get("inline") === "1"; - - try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const tempDir = join(tmpdir(), "pi-web-export"); - mkdirSync(tempDir, { recursive: true }); - - const sessionBase = basename(filePath, ".jsonl"); - const fileName = `pi-session-${sessionBase}.html`; - const outputPath = join(tempDir, `${randomUUID()}.html`); - - try { - await exportSession(filePath, outputPath); - - const html = readFileSync(outputPath, "utf8"); - const patchedHtml = patchExportHtml(html); - return new Response(patchedHtml, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Content-Disposition": getContentDisposition(fileName, inline), - "Cache-Control": "no-cache", - "Content-Security-Policy": "frame-ancestors 'none'", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - }, - }); - } finally { - rmSync(outputPath, { force: true }); - } - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/sessions/[id]/route.ts b/pi-web/app/api/sessions/[id]/route.ts deleted file mode 100644 index 3182239..0000000 --- a/pi-web/app/api/sessions/[id]/route.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { NextResponse } from "next/server"; -import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"; -import { dirname, join } from "path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; -import { - resolveSessionPath, - resolveSessionIdByPath, - invalidateSessionPathCache, - invalidateSessionListCache, - buildSessionContext, - readSessionHeader, -} from "@/lib/session-reader"; -import { sessionPathKey } from "@/lib/session-path"; -import { getRpcSession } from "@/lib/rpc-manager"; - -// BranchNavigator still traverses recursively, so keep the response tree shallow. -const MAX_PROJECTED_TREE_DEPTH = 200; - -/** - * Project the session tree into the shallow navigation tree sent to the client. - * Keeps roots, branch points, and leaves while contracting single-child chains - * without recursive traversal. Contracted entry IDs are attached to the next - * visible node so the UI can still recognize an active leaf inside the chain. - */ -function projectTreeForResponse( - nodes: T[] -): T[] { - const keep = new Set(); - const roots = new Set(nodes); - const seen = new Set(); - const stack = [...nodes]; - - while (stack.length > 0) { - const node = stack.pop()!; - if (seen.has(node)) continue; - seen.add(node); - - if ( - roots.has(node) || - node.children.length !== 1 - ) { - keep.add(node); - } - - for (const child of node.children) { - stack.push(child); - } - } - - const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({ - ...node, - children: [], - ...(compressedEntryIds?.length ? { compressedEntryIds } : {}), - }); - const projectedRoots = nodes.map((node) => cloneNode(node)); - const tasks = nodes.map((source, index) => ({ - source, - projected: projectedRoots[index], - depth: 1, - })); - - const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => { - const pending = [{ node: source, compressedEntryIds: [] as string[] }]; - const flattenedSeen = new Set(); - - while (pending.length > 0) { - const { node, compressedEntryIds } = pending.pop()!; - if (flattenedSeen.has(node)) continue; - flattenedSeen.add(node); - - if (keep.has(node)) { - projectedParent.children.push(cloneNode(node, compressedEntryIds)); - } - - for (let i = node.children.length - 1; i >= 0; i--) { - pending.push({ - node: node.children[i], - compressedEntryIds: keep.has(node) - ? [] - : [...compressedEntryIds, node.entry.id], - }); - } - } - }; - - while (tasks.length > 0) { - const { source, projected, depth } = tasks.pop()!; - - for (const sourceChild of source.children) { - let child = sourceChild; - - if (depth >= MAX_PROJECTED_TREE_DEPTH) { - appendFlattenedKeptDescendants(child, projected); - continue; - } - - const compressedEntryIds: string[] = []; - while (!keep.has(child) && child.children.length === 1) { - compressedEntryIds.push(child.entry.id); - child = child.children[0]; - } - - if (!keep.has(child)) { - continue; - } - - const projectedChild = cloneNode(child, compressedEntryIds); - projected.children.push(projectedChild); - tasks.push({ source: child, projected: projectedChild, depth: depth + 1 }); - } - } - - return projectedRoots; -} - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const sm = SessionManager.open(filePath); - const entries = sm.getEntries() as never; - const leafId = sm.getLeafId(); - const tree = projectTreeForResponse(sm.getTree()); - const searchParams = new URL(req.url).searchParams; - const deferThinking = searchParams.has("deferThinking"); - const deferToolResultImages = searchParams.has("deferMedia"); - const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages }); - - const header = sm.getHeader(); - let modified = header?.timestamp ?? new Date().toISOString(); - try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ } - const parentSessionId = header?.parentSession - ? await resolveSessionIdByPath(header.parentSession) - : undefined; - const info = header ? { - path: filePath, - id: header.id, - cwd: header.cwd ?? "", - name: sm.getSessionName(), - created: header.timestamp, - modified, - messageCount: context.messages.length, - firstMessage: context.messages.find((m) => m.role === "user") - ? (() => { - const msg = context.messages.find((m) => m.role === "user")!; - const c = (msg as { content: unknown }).content; - return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)"; - })() - : "(no messages)", - parentSessionId, - } : null; - - return NextResponse.json({ - sessionId: id, - filePath, - info, - leafId, - tree, - context, - }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// PATCH /api/sessions/[id] body: { name: string } -export async function PATCH( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - const { name } = await req.json() as { name?: string }; - if (typeof name !== "string") { - return NextResponse.json({ error: "name is required" }, { status: 400 }); - } - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - const sm = SessionManager.open(filePath); - sm.appendSessionInfo(name.trim()); - invalidateSessionListCache(); - return NextResponse.json({ ok: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// DELETE /api/sessions/[id] -export async function DELETE( - _req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - try { - const filePath = await resolveSessionPath(id); - if (!filePath) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - // Read only the bounded header before deleting. - const parentSessionPath = readSessionHeader(filePath)?.parentSession; - - // Re-attach all direct children to this session's parent (cascade re-parent) - // Scan sibling files in the same directory - const targetPathKey = sessionPathKey(filePath); - const dir = dirname(filePath); - try { - const files = readdirSync(dir).filter( - (file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey, - ); - for (const file of files) { - const childPath = join(dir, file); - try { - const content = readFileSync(childPath, "utf8"); - const lines = content.split("\n"); - const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string }; - if ( - header.type === "session" && - header.parentSession && - sessionPathKey(header.parentSession) === targetPathKey - ) { - // Rewrite header with new parentSession - header.parentSession = parentSessionPath; - lines[0] = JSON.stringify(header); - writeFileSync(childPath, lines.join("\n")); - } - } catch { /* skip malformed */ } - } - } catch { /* skip if dir unreadable */ } - - getRpcSession(id)?.destroy(); - unlinkSync(filePath); - invalidateSessionPathCache(id); - invalidateSessionListCache(); - return NextResponse.json({ ok: true }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/sessions/[id]/state/route.ts b/pi-web/app/api/sessions/[id]/state/route.ts deleted file mode 100644 index d384e78..0000000 --- a/pi-web/app/api/sessions/[id]/state/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import { getRpcSession } from "@/lib/rpc-manager"; -import { resolveSessionPath } from "@/lib/session-reader"; - -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params; - try { - if (!await resolveSessionPath(id)) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const rpc = getRpcSession(id); - if (!rpc?.isAlive()) return NextResponse.json({ running: false }); - - const state = await rpc.send({ type: "get_state" }); - return NextResponse.json({ running: true, state }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/sessions/route.ts b/pi-web/app/api/sessions/route.ts deleted file mode 100644 index c575b4b..0000000 --- a/pi-web/app/api/sessions/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { listAllSessions } from "@/lib/session-reader"; -import { getRunningRpcSessionIds } from "@/lib/rpc-manager"; - -export async function GET() { - try { - const sessions = await listAllSessions(); - return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() }); - } catch (error) { - return NextResponse.json( - { error: String(error) }, - { status: 500 } - ); - } -} diff --git a/pi-web/app/api/skills/check/route.ts b/pi-web/app/api/skills/check/route.ts deleted file mode 100644 index 4f1da15..0000000 --- a/pi-web/app/api/skills/check/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextResponse } from "next/server"; -import type { SkillInstallScope } from "@/lib/api-types"; -import { checkSkillUpdates } from "@/lib/skill-updates"; -import { loadSkillsWithInstallInfo } from "@/lib/skills-service"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; - -export const dynamic = "force-dynamic"; - -export async function POST(req: Request) { - try { - const body = await req.json() as { - cwd?: unknown; - package?: unknown; - scope?: unknown; - }; - const cwd = typeof body.cwd === "string" ? body.cwd : ""; - if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const pkg = typeof body.package === "string" ? body.package : undefined; - const scope = body.scope === "global" || body.scope === "project" - ? body.scope as SkillInstallScope - : undefined; - if ((pkg && !scope) || (!pkg && scope)) { - return NextResponse.json({ error: "package and scope must be provided together" }, { status: 400 }); - } - - const { skills } = await loadSkillsWithInstallInfo(cwd); - const installs = skills - .map((skill) => skill.install) - .filter((install): install is NonNullable => Boolean(install)) - .filter((install) => !pkg || (install.package === pkg && install.scope === scope)); - - if (pkg && installs.length === 0) { - return NextResponse.json({ error: "Installed skill not found" }, { status: 404 }); - } - - const updates = await checkSkillUpdates(installs, { - githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, - }); - return NextResponse.json({ updates }); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500 }, - ); - } -} diff --git a/pi-web/app/api/skills/install/route.ts b/pi-web/app/api/skills/install/route.ts deleted file mode 100644 index 317aef9..0000000 --- a/pi-web/app/api/skills/install/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { runNpx } from "@/lib/npx"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; -import { getProjectTrustStatus } from "@/lib/project-trust"; -import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime"; - -export const dynamic = "force-dynamic"; - -const ANSI_RE = /\x1B\[[0-9;]*m/g; - -// POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string } -export async function POST(req: Request) { - if (!isApiRequestAllowed(req)) { - return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); - } - if (!hasJsonContentType(req)) { - return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); - } - - try { - const { package: pkg, scope, cwd } = await req.json() as { package?: string; scope?: string; cwd?: string }; - if (!pkg?.trim()) return NextResponse.json({ error: "package required" }, { status: 400 }); - - const isGlobal = isManagedRuntime() || scope !== "project"; - if (!isGlobal) { - if (!cwd) return NextResponse.json({ error: "cwd required for project install" }, { status: 400 }); - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) { - return NextResponse.json( - { error: "Project resources must be trusted before installing project skills" }, - { status: 403 }, - ); - } - } - const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"]; - if (isGlobal) args.push("-g"); - - console.log(`[skills/install] running: npx ${args.join(" ")}`); - const { stdout, stderr } = await runNpx(args, { - timeout: 60000, - cwd: !isGlobal && cwd ? cwd : undefined, - env: getSkillsCliEnvironment(), - }); - - const output = (stdout + stderr).replace(ANSI_RE, ""); - const success = /Installation complete|Installed \d+ skill/.test(output); - if (!success) { - return NextResponse.json({ error: output.slice(-300) || "Install failed" }, { status: 500 }); - } - return NextResponse.json({ success: true, output }); - } catch (e: unknown) { - const err = e as { stdout?: string; stderr?: string; message?: string }; - const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, ""); - return NextResponse.json({ error: output || (err.message ?? String(e)) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/skills/route.ts b/pi-web/app/api/skills/route.ts deleted file mode 100644 index dfa4f7b..0000000 --- a/pi-web/app/api/skills/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextResponse } from "next/server"; -import { existsSync, readFileSync, writeFileSync } from "fs"; -import { homedir } from "os"; -import path from "path"; -import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; -import { loadSkillsWithInstallInfo } from "@/lib/skills-service"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime"; - -export const dynamic = "force-dynamic"; - -// GET /api/skills?cwd= -// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json -// skill paths, package skills, and .agents/skills directories are all included. -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const cwd = searchParams.get("cwd"); - if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); - - try { - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - return NextResponse.json(await loadSkillsWithInstallInfo(cwd)); - } catch (e) { - return NextResponse.json({ error: String(e) }, { status: 500 }); - } -} - -// PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file -export async function PATCH(req: Request) { - try { - const body = await req.json() as { filePath: string; disableModelInvocation: boolean }; - const { filePath, disableModelInvocation } = body; - if (!filePath) return NextResponse.json({ error: "filePath required" }, { status: 400 }); - if (!existsSync(filePath)) return NextResponse.json({ error: "file not found" }, { status: 404 }); - const allowedRoots = new Set(await getAllowedFileRoots()); - allowedRoots.add(getAgentDir()); - if (isManagedRuntime()) { - for (const root of getManagedRuntimePaths().managedSkillRoots) { - if (existsSync(root)) allowedRoots.add(root); - } - } else { - // Upstream-compatible mode keeps the CLI's user-wide skill root. - const globalSkillsDir = path.join(homedir(), ".agents", "skills"); - if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir); - } - if (!isExistingFilePathAllowed(filePath, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const content = readFileSync(filePath, "utf8"); - const key = "disable-model-invocation"; - - // Use parseFrontmatter to check current value, then do a surgical line edit - // to preserve the original YAML formatting of all other fields. - const { frontmatter } = parseFrontmatter>(content); - const alreadySet = Boolean(frontmatter[key]); - - let updated = content; - if (disableModelInvocation && !alreadySet) { - // Add key after the opening --- line - updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`); - // If no frontmatter exists, create one - if (updated === content) updated = `---\n${key}: true\n---\n${content}`; - } else if (!disableModelInvocation && alreadySet) { - // Remove the key line entirely - updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), ""); - } - - writeFileSync(filePath, updated, "utf8"); - return NextResponse.json({ success: true }); - } catch (e) { - return NextResponse.json({ error: String(e) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/skills/search/route.ts b/pi-web/app/api/skills/search/route.ts deleted file mode 100644 index 6bce20a..0000000 --- a/pi-web/app/api/skills/search/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextResponse } from "next/server"; -import { runNpx } from "@/lib/npx"; -import type { SkillSearchResult } from "@/lib/api-types"; - -export const dynamic = "force-dynamic"; - -const ANSI_RE = /\x1B\[[0-9;]*m/g; -const DEFAULT_LIMIT = 50; -const MIN_LIMIT = 1; -const MAX_LIMIT = 50; -const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh"; - -interface SkillsApiSkill { - id?: string; - name?: string; - source?: string; - installs?: number; -} - -interface SkillsApiResponse { - skills?: SkillsApiSkill[]; -} - -function parseLimit(value: unknown): number { - const num = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(num)) return DEFAULT_LIMIT; - return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num))); -} - -function formatInstalls(count?: number): string { - if (!count || count <= 0) return ""; - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; - return `${count} install${count === 1 ? "" : "s"}`; -} - -function parseSearchOutput(raw: string): SkillSearchResult[] { - const clean = raw.replace(ANSI_RE, ""); - const results: SkillSearchResult[] = []; - const lines = clean.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - // package line: "owner/repo@skill NNK installs" - const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/); - if (pkgMatch) { - const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, ""); - results.push({ - package: pkgMatch[1], - installs: pkgMatch[2], - url: urlLine?.startsWith("https://") ? urlLine : "", - }); - } - } - return results; -} - -async function searchSkillsApi(query: string, limit: number): Promise { - const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`; - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`); - - const data = (await res.json()) as SkillsApiResponse; - return (data.skills ?? []) - .map((skill) => { - const name = skill.name?.trim(); - const source = skill.source?.trim(); - const slug = skill.id?.trim(); - if (!name || (!source && !slug)) return null; - - const pkg = `${source || slug}@${name}`; - return { - package: pkg, - installs: formatInstalls(skill.installs), - url: slug ? `${SEARCH_API_BASE}/${slug}` : "", - }; - }) - .filter((skill): skill is SkillSearchResult => skill !== null) - .sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs)); -} - -function parseInstallCount(installs: string): number { - const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/); - if (!match) return 0; - const value = Number(match[1]); - if (!Number.isFinite(value)) return 0; - const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1; - return value * multiplier; -} - -// POST /api/skills/search body: { query: string, limit?: number } -export async function POST(req: Request) { - try { - const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown }; - if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 }); - const limit = parseLimit(rawLimit); - - try { - const results = await searchSkillsApi(query.trim(), limit); - return NextResponse.json({ results }); - } catch { - const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], { - timeout: 20000, - env: { ...process.env, FORCE_COLOR: "0" }, - }); - - const results = parseSearchOutput(stdout + stderr).slice(0, limit); - return NextResponse.json({ results }); - } - } catch (e: unknown) { - const err = e as { stdout?: string; stderr?: string; message?: string }; - const raw = (err.stdout ?? "") + (err.stderr ?? ""); - const results = raw ? parseSearchOutput(raw) : []; - if (results.length > 0) return NextResponse.json({ results }); - return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/skills/update/route.ts b/pi-web/app/api/skills/update/route.ts deleted file mode 100644 index 8563e78..0000000 --- a/pi-web/app/api/skills/update/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextResponse } from "next/server"; -import { runNpx } from "@/lib/npx"; -import type { SkillInstallScope } from "@/lib/api-types"; -import { buildSkillUpdateArgs } from "@/lib/skill-updates"; -import { loadSkillsWithInstallInfo } from "@/lib/skills-service"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; -import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime"; - -export const dynamic = "force-dynamic"; - -export async function POST(req: Request) { - try { - const body = await req.json() as { - cwd?: unknown; - package?: unknown; - scope?: unknown; - }; - const cwd = typeof body.cwd === "string" ? body.cwd : ""; - const pkg = typeof body.package === "string" ? body.package : ""; - const scope = body.scope === "global" || body.scope === "project" - ? body.scope as SkillInstallScope - : undefined; - if (!cwd || !pkg || !scope) { - return NextResponse.json({ error: "cwd, package, and scope are required" }, { status: 400 }); - } - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const { skills } = await loadSkillsWithInstallInfo(cwd); - const skill = skills.find( - (item) => item.install?.package === pkg && item.install.scope === scope, - ); - if (!skill?.install) { - return NextResponse.json({ error: "Installed skill not found" }, { status: 404 }); - } - if (!skill.install.canCheckForUpdates) { - return NextResponse.json({ error: "This skill cannot be updated automatically" }, { status: 400 }); - } - - const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), { - timeout: 60_000, - cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined, - env: getSkillsCliEnvironment(), - }); - - const refreshed = await loadSkillsWithInstallInfo(cwd); - const updatedSkill = refreshed.skills.find( - (item) => item.install?.package === pkg && item.install.scope === scope, - ); - return NextResponse.json({ - success: true, - skill: updatedSkill, - output: `${stdout}${stderr}`.slice(-500), - }); - } catch (error: unknown) { - const detail = error as { stdout?: string; stderr?: string; message?: string }; - const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`; - return NextResponse.json( - { error: output || detail.message || String(error) }, - { status: 500 }, - ); - } -} diff --git a/pi-web/app/api/vision-config/route.ts b/pi-web/app/api/vision-config/route.ts deleted file mode 100644 index 72fad96..0000000 --- a/pi-web/app/api/vision-config/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextResponse } from "next/server"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; - -export const dynamic = "force-dynamic"; - -interface VisionConfigFile { - backend?: "ollama" | "openai"; - ollama?: { host?: string; model?: string }; - openai?: { baseUrl?: string; apiKey?: string; model?: string }; -} - -function getVisionPath(): string { - return join(getAgentDir(), "vision.json"); -} - -function readVisionJson(): VisionConfigFile { - const path = getVisionPath(); - if (!existsSync(path)) return {}; - try { - const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile; - if (!parsed || typeof parsed !== "object") return {}; - return parsed; - } catch { - return {}; - } -} - -function writeVisionJson(data: VisionConfigFile): void { - const path = getVisionPath(); - const dir = dirname(path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); -} - -export function GET() { - return NextResponse.json({ config: readVisionJson(), path: getVisionPath() }); -} - -export async function PUT(req: Request) { - try { - const body = (await req.json()) as VisionConfigFile; - if (!body || typeof body !== "object") { - return NextResponse.json({ error: "body must be an object" }, { status: 400 }); - } - const out: VisionConfigFile = {}; - if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend; - if (body.ollama && typeof body.ollama === "object") { - out.ollama = {}; - if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim(); - if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim(); - } - if (body.openai && typeof body.openai === "object") { - out.openai = {}; - if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim(); - if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim(); - if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim(); - } - writeVisionJson(out); - return NextResponse.json({ success: true, path: getVisionPath() }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} diff --git a/pi-web/app/api/worktrees/route.ts b/pi-web/app/api/worktrees/route.ts deleted file mode 100644 index c278c5f..0000000 --- a/pi-web/app/api/worktrees/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { NextResponse } from "next/server"; -import { existsSync } from "fs"; -import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree"; -import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access"; - -/** Same gate as /api/files: only session cwds / project roots / explicitly - * allowed dirs may be inspected or mutated through this endpoint. */ -async function checkCwdAllowed(cwd: string): Promise { - const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - return null; -} - -// GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees } -export async function GET(req: Request) { - try { - const cwd = new URL(req.url).searchParams.get("cwd"); - if (!cwd) { - return NextResponse.json({ error: "cwd is required" }, { status: 400 }); - } - const denied = await checkCwdAllowed(cwd); - if (denied) return denied; - - const project = await resolveProject(cwd); - let worktrees: Awaited> = []; - let isGit = true; - try { - // For a removed-worktree cwd (session of a deleted worktree), fall back - // to the inferred project root so the switcher still shows the project. - worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot); - } catch { - isGit = false; - } - // Every listed path is a git-verified worktree of this project; allow the - // file explorer to browse them even before they have any session (the - // in-memory allowlist from addWorktree does not survive server restarts). - for (const w of worktrees) allowFileRoot(w.path); - return NextResponse.json({ - projectRoot: project.projectRoot, - isGit, - isTopLevel: project.isTopLevel, - worktrees, - }); - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); - } -} - -// POST /api/worktrees body: { cwd, branch } → { path, branch } -export async function POST(req: Request) { - try { - const body = await req.json() as { cwd?: string; branch?: string }; - if (!body.cwd || typeof body.cwd !== "string") { - return NextResponse.json({ error: "cwd is required" }, { status: 400 }); - } - if (!body.branch || typeof body.branch !== "string") { - return NextResponse.json({ error: "branch is required" }, { status: 400 }); - } - const denied = await checkCwdAllowed(body.cwd); - if (denied) return denied; - if (!existsSync(body.cwd)) { - return NextResponse.json({ error: `Directory does not exist: ${body.cwd}` }, { status: 400 }); - } - - const result = await addWorktree(body.cwd, body.branch); - return NextResponse.json(result); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return NextResponse.json({ error: message }, { status: 400 }); - } -} - -// DELETE /api/worktrees body: { cwd, path, force? } -export async function DELETE(req: Request) { - try { - const body = await req.json() as { cwd?: string; path?: string; force?: boolean }; - if (!body.cwd || typeof body.cwd !== "string") { - return NextResponse.json({ error: "cwd is required" }, { status: 400 }); - } - if (!body.path || typeof body.path !== "string") { - return NextResponse.json({ error: "path is required" }, { status: 400 }); - } - const denied = await checkCwdAllowed(body.cwd); - if (denied) return denied; - - await removeWorktree(body.cwd, body.path, body.force === true); - return NextResponse.json({ success: true }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - // git refuses to remove dirty worktrees without --force; surface that so - // the UI can offer a force-remove confirmation. - const dirty = /contains modified or untracked files|is dirty/i.test(message); - return NextResponse.json({ error: message, dirty }, { status: dirty ? 409 : 400 }); - } -} diff --git a/pi-web/instrumentation.ts b/pi-web/instrumentation.ts deleted file mode 100644 index 98506a5..0000000 --- a/pi-web/instrumentation.ts +++ /dev/null @@ -1,6 +0,0 @@ -export async function register(): Promise { - if (process.env.NEXT_RUNTIME !== "nodejs") return; - - const { configureHttpDispatcher } = await import("@/lib/http-dispatcher"); - configureHttpDispatcher(); -} diff --git a/pi-web/lib/api-types.ts b/pi-web/lib/api-types.ts index 361388c..7403f4c 100644 --- a/pi-web/lib/api-types.ts +++ b/pi-web/lib/api-types.ts @@ -1,4 +1,20 @@ -import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; +// Structural copy of pi-coding-agent's ResourceDiagnostic/ResourceCollision so +// the frontend no longer depends on the pi packages (the backend owns them). +export interface ResourceCollision { + resourceType: "extension" | "skill" | "prompt" | "theme"; + name: string; + winnerPath: string; + loserPath: string; + winnerSource?: string; + loserSource?: string; +} + +export interface ResourceDiagnostic { + type: "warning" | "error" | "collision"; + message: string; + path?: string; + collision?: ResourceCollision; +} export interface SkillSearchResult { package: string; diff --git a/pi-web/lib/http-dispatcher.test.mjs b/pi-web/lib/http-dispatcher.test.mjs deleted file mode 100644 index fd3c8b1..0000000 --- a/pi-web/lib/http-dispatcher.test.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import { once } from "node:events"; -import test from "node:test"; -import { createJiti } from "jiti"; - -const PROXY_ENV_KEYS = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "NO_PROXY", - "http_proxy", - "https_proxy", - "no_proxy", - "ALL_PROXY", - "all_proxy", -]; - -test("configures HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for global fetch", async (t) => { - const originalEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])); - for (const key of PROXY_ENV_KEYS) delete process.env[key]; - - const connectTargets = []; - const tunneledRequests = []; - const proxy = createServer((req, res) => { - res.writeHead(204, { Connection: "close" }); - res.end(); - }); - proxy.on("connect", (req, socket) => { - connectTargets.push(req.url); - if (req.url?.endsWith(":80")) { - socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - socket.once("data", (chunk) => { - tunneledRequests.push(chunk.toString("utf8").split("\r\n", 1)[0]); - socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); - }); - return; - } - socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); - }); - proxy.listen(0, "127.0.0.1"); - await once(proxy, "listening"); - - t.after(async () => { - for (const [key, value] of originalEnv) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - await new Promise((resolve, reject) => { - proxy.close((error) => error ? reject(error) : resolve()); - }); - }); - - const address = proxy.address(); - assert.ok(address && typeof address === "object"); - const proxyUrl = `http://127.0.0.1:${address.port}`; - process.env.HTTP_PROXY = proxyUrl; - process.env.HTTPS_PROXY = proxyUrl; - process.env.NO_PROXY = "bypass.invalid"; - - const jiti = createJiti(import.meta.url); - const { configureHttpDispatcher } = await jiti.import("./http-dispatcher.ts"); - const { getGlobalDispatcher } = await import("undici"); - - assert.throws(() => configureHttpDispatcher(-1), /Invalid HTTP idle timeout/); - configureHttpDispatcher(2_000); - - const dispatcher = getGlobalDispatcher(); - configureHttpDispatcher(5_000); - assert.equal(getGlobalDispatcher(), dispatcher, "configuration should be idempotent"); - - const httpResponse = await fetch("http://target.invalid/through-http-proxy", { - signal: AbortSignal.timeout(2_000), - }); - assert.equal(httpResponse.status, 204); - assert.deepEqual(connectTargets, ["target.invalid:80"]); - assert.deepEqual(tunneledRequests, ["GET /through-http-proxy HTTP/1.1"]); - - await assert.rejects(fetch("https://target.invalid/through-https-proxy", { - signal: AbortSignal.timeout(2_000), - })); - assert.deepEqual(connectTargets, ["target.invalid:80", "target.invalid:443"]); - - const proxiedRequestCount = connectTargets.length; - await assert.rejects(fetch("http://bypass.invalid:9/no-proxy", { - signal: AbortSignal.timeout(2_000), - })); - assert.equal(connectTargets.length, proxiedRequestCount); -}); diff --git a/pi-web/lib/http-dispatcher.ts b/pi-web/lib/http-dispatcher.ts deleted file mode 100644 index 7d99505..0000000 --- a/pi-web/lib/http-dispatcher.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { EventEmitter } from "node:events"; -import * as undici from "undici"; - -export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; - -type DispatcherGlobal = typeof globalThis & { - __piWebHttpDispatcherConfigured?: boolean; -}; - -const dispatcherGlobal = globalThis as DispatcherGlobal; -const originalGlobalFetch = globalThis.fetch; -const ignoreUndiciDispatcherError = (): void => {}; - -function parseHttpIdleTimeoutMs(value: unknown): number | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed.toLowerCase() === "disabled") return 0; - if (trimmed.length === 0) return undefined; - return parseHttpIdleTimeoutMs(Number(trimmed)); - } - - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return undefined; - } - return Math.floor(value); -} - -// Undici can emit an internal Client error while terminating a response body. -// The body stream still rejects; this prevents the EventEmitter error from -// terminating the Next.js process first. -function withUndiciErrorListener(dispatcher: T): T { - if (dispatcher instanceof EventEmitter) { - EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError); - } - return dispatcher; -} - -function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher { - return withUndiciErrorListener( - new undici.Client(origin, options as undici.Client.Options), - ); -} - -function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher { - const dispatcherOptions = options as undici.Pool.Options; - if (dispatcherOptions.connections === 1) { - return createUndiciClient(origin, dispatcherOptions); - } - - return withUndiciErrorListener( - new undici.Pool(origin, { - ...dispatcherOptions, - factory: createUndiciClient, - }), - ); -} - -export function configureHttpDispatcher( - timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS, -): void { - if (dispatcherGlobal.__piWebHttpDispatcherConfigured) return; - - const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); - if (normalizedTimeoutMs === undefined) { - throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); - } - - const dispatcher = withUndiciErrorListener( - new undici.EnvHttpProxyAgent({ - allowH2: false, - bodyTimeout: normalizedTimeoutMs, - headersTimeout: normalizedTimeoutMs, - clientFactory: createUndiciClient, - factory: createUndiciOriginDispatcher, - }), - ); - undici.setGlobalDispatcher(dispatcher); - - // Keep fetch and the dispatcher on the same undici implementation. Preserve - // an intentional fetch override installed after this module was loaded. - if (globalThis.fetch === originalGlobalFetch) { - undici.install?.(); - } - - dispatcherGlobal.__piWebHttpDispatcherConfigured = true; -} diff --git a/pi-web/lib/pi-types.ts b/pi-web/lib/pi-types.ts index c2b68e2..9ada70d 100644 --- a/pi-web/lib/pi-types.ts +++ b/pi-web/lib/pi-types.ts @@ -1,10 +1,7 @@ -import type { - AgentSessionEvent, - SessionManager, - SettingsManager, - SlashCommandInfo, - Theme, -} from "@earendil-works/pi-coding-agent"; +// Frontend-only subset of the pi type surface. The full AgentSessionLike +// contract (which references pi-coding-agent classes) lives in the backend +// (server/src/lib/pi-types.ts); the UI only renders session stats reported +// over HTTP, so this file must not import the pi packages. export interface ContextUsage { percent: number | null; @@ -12,22 +9,6 @@ export interface ContextUsage { tokens: number | null; } -export interface ModelLike { - id: string; - provider: string; -} - -export interface ToolInfo { - name: string; - description: string; -} - -export interface NavigateTreeResult { - editorText?: string; - cancelled: boolean; - aborted?: boolean; -} - export interface SessionStatsInfo { sessionFile?: string; sessionId: string; @@ -47,121 +28,3 @@ export interface SessionStatsInfo { cost: number; contextUsage?: ContextUsage; } - -interface PromptTemplateLike { - name: string; - description?: string; - sourceInfo: SlashCommandInfo["sourceInfo"]; -} - -interface SkillLike { - name: string; - description?: string; - sourceInfo: SlashCommandInfo["sourceInfo"]; -} - -interface ResourceLoaderLike { - getSkills(): { skills: SkillLike[] }; -} - -interface ExtensionRunnerLike { - getRegisteredCommands(): Array<{ - invocationName: string; - description?: string; - sourceInfo: SlashCommandInfo["sourceInfo"]; - }>; - setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void; -} - -type DialogOptionsLike = { - signal?: AbortSignal; - timeout?: number; -}; - -type WidgetOptionsLike = { - placement?: "aboveEditor" | "belowEditor"; -}; - -export interface ExtensionUiContextLike { - select(title: string, options: string[], opts?: DialogOptionsLike): Promise; - confirm(title: string, message: string, opts?: DialogOptionsLike): Promise; - input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise; - editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise; - notify(message: string, type?: "info" | "warning" | "error"): void; - onTerminalInput(): () => void; - setStatus(key: string, text: string | undefined): void; - setWorkingMessage(message?: string): void; - setWorkingVisible(visible: boolean): void; - setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void; - setHiddenThinkingLabel(label?: string): void; - setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void; - setFooter(factory: unknown): void; - setHeader(factory: unknown): void; - setTitle(title: string): void; - custom(...args: unknown[]): Promise; - pasteToEditor(text: string): void; - setEditorText(text: string): void; - getEditorText(): string; - addAutocompleteProvider(): void; - setEditorComponent(): void; - getEditorComponent(): undefined; - readonly theme: Theme; - getAllThemes(): unknown[]; - getTheme(name: string): undefined; - setTheme(theme: unknown): { success: boolean; error?: string }; - getToolsExpanded(): boolean; - setToolsExpanded(expanded: boolean): void; -} - -export interface AgentSessionLike { - readonly sessionId: string; - readonly sessionFile: string | undefined; - readonly isStreaming: boolean; - readonly isCompacting: boolean; - readonly autoCompactionEnabled: boolean; - readonly autoRetryEnabled: boolean; - readonly model: ModelLike | undefined; - readonly modelRuntime: { - getModel: (provider: string, modelId: string) => ModelLike | undefined; - refresh: (options?: { allowNetwork?: boolean }) => Promise; - }; - readonly sessionManager: SessionManager; - readonly settingsManager: SettingsManager; - readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } }; - readonly extensionRunner: ExtensionRunnerLike; - readonly promptTemplates: readonly PromptTemplateLike[]; - readonly resourceLoader: ResourceLoaderLike; - - readonly bindExtensions?: unknown; - reload(options?: { beforeSessionStart?: () => void | Promise }): Promise; - subscribe(listener: (event: AgentSessionEvent) => void): () => void; - prompt(text: string, options?: { - images?: Array<{ type: "image"; data: string; mimeType: string }>; - streamingBehavior?: "steer" | "followUp"; - source?: "interactive" | "rpc"; - }): Promise; - abort(): Promise; - executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>; - abortBash(): void; - readonly isBashRunning: boolean; - setModel(model: ModelLike): Promise; - navigateTree(targetId: string, options?: { summarize?: boolean }): Promise; - setThinkingLevel(level: string): void; - compact(customInstructions?: string): Promise; - setSessionName(name: string): void; - getSessionStats(): Omit; - getLastAssistantText(): string | undefined; - setAutoCompactionEnabled(enabled: boolean): void; - setAutoRetryEnabled(enabled: boolean): void; - steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; - followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; - readonly pendingMessageCount: number; - getSteeringMessages(): readonly string[]; - getFollowUpMessages(): readonly string[]; - clearQueue(): { steering: string[]; followUp: string[] }; - getAllTools(): ToolInfo[]; - getActiveToolNames(): string[]; - setActiveToolsByName(names: string[]): void; - abortCompaction(): void; - getContextUsage(): ContextUsage | undefined; -} diff --git a/pi-web/next.config.ts b/pi-web/next.config.ts index d2cb62b..e0835ff 100644 --- a/pi-web/next.config.ts +++ b/pi-web/next.config.ts @@ -5,7 +5,7 @@ import { join } from "path"; const { version } = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")) as { version: string }; let piVersion = "unknown"; try { - const piPkgPath = join(__dirname, "node_modules/@earendil-works/pi-coding-agent/package.json"); + const piPkgPath = join(__dirname, "..", "server", "node_modules", "@earendil-works", "pi-coding-agent", "package.json"); piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version; } catch { /* package not found, use default */ } @@ -14,16 +14,28 @@ const allowedDevOrigins = [ ...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []), ].map((origin) => origin.trim()).filter(Boolean); +// All /api routes are served by the standalone backend (pi-agent-server) so +// agent sessions and server state survive frontend recompiles and restarts. +const serverOrigin = `http://127.0.0.1:${process.env.PI_SERVER_PORT ?? "30142"}`; + const nextConfig: NextConfig = { devIndicators: false, - serverExternalPackages: [ - "undici", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-agent-core", - "@earendil-works/pi-ai", - "@earendil-works/pi-tui", - ], allowedDevOrigins, + async rewrites() { + // beforeFiles is required: default (afterFiles) rewrites lose to the + // existing app/api route handlers in dev, so the migrated routes + // would keep hitting the local copies instead of the backend. + return { + beforeFiles: [ + { + source: "/api/:path*", + destination: `${serverOrigin}/api/:path*`, + }, + ], + afterFiles: [], + fallback: [], + }; + }, async headers() { return [ { diff --git a/pi-web/package-lock.json b/pi-web/package-lock.json index a783fea..a695407 100644 --- a/pi-web/package-lock.json +++ b/pi-web/package-lock.json @@ -9,14 +9,9 @@ "version": "0.8.4", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "file:../pi/packages/agent", - "@earendil-works/pi-ai": "file:../pi/packages/ai", - "@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent", - "@earendil-works/pi-tui": "file:../pi/packages/tui", "next": "16.2.12", "react": "^19.2.4", - "react-dom": "^19.2.4", - "undici": "8.5.0" + "react-dom": "^19.2.4" }, "bin": { "pi-web": "bin/pi-web.js" @@ -30,9 +25,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", "eslint-config-next": "16.2.12", - "jiti": "^2.7.0", "katex": "^0.16.47", - "mammoth": "^1.12.0", "mermaid": "^11.14.0", "postcss": "^8", "react-markdown": "^10.1.0", @@ -182,458 +175,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", - "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.19", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.977.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.3.tgz", - "integrity": "sha512-6YZTpF5Zzl0KdSrztM0l6ErKdnXrnnodIDYfayHE9SFfZU8Ubxls7H2XnfWT121jgwiG+WP1z5kyfVDsH5V4qA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", - "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.31.1", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.64", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.64.tgz", - "integrity": "sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.66.tgz", - "integrity": "sha512-BrZxoXScBZ+nTOYy388zHkz1n5cS8C+uGFMozD4w9OKEWMq39Cu/9l/k385Hb54dtv0VIeh12f8h67o3xDNH9g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { - "version": "4.9.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", - "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.9.tgz", - "integrity": "sha512-d/mMvS+sQjSWNA6+JCldK1YB/jQmtoWM8Izj10mrOZdRCeWNVb8IYQuuyHHKKKXcpGo5Pd0LIK6onHbLvlhuVQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/credential-provider-env": "^3.972.64", - "@aws-sdk/credential-provider-http": "^3.972.66", - "@aws-sdk/credential-provider-login": "^3.972.71", - "@aws-sdk/credential-provider-process": "^3.972.64", - "@aws-sdk/credential-provider-sso": "^3.973.8", - "@aws-sdk/credential-provider-web-identity": "^3.972.70", - "@aws-sdk/nested-clients": "^3.997.38", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.71.tgz", - "integrity": "sha512-l93922lXnTs5RjM3QrMCXSmXiEgLFLaQ0Lco9F3tJ08o0mzGdAj6m2nAXDuTMHoUuL0u7RKmrhm+Z8/7GCilyg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/nested-clients": "^3.997.38", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.75.tgz", - "integrity": "sha512-sdFhR4E3HWZefGL6OsBhxqXdqtFvxGOh8VLfiu9yttPgVNcpEozEDPmrTYmXA3CIf6npUETVLveQPGQ7GBzVhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.64", - "@aws-sdk/credential-provider-http": "^3.972.66", - "@aws-sdk/credential-provider-ini": "^3.973.9", - "@aws-sdk/credential-provider-process": "^3.972.64", - "@aws-sdk/credential-provider-sso": "^3.973.8", - "@aws-sdk/credential-provider-web-identity": "^3.972.70", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.64", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.64.tgz", - "integrity": "sha512-Q5noG5vfFo++bUaLGcjj8OHX5tmwa1O/5nDVMeaSSZgjzgvWbW6tgu/gSa+aENwcJXjoXDQh+8TRZR2zHds/aw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.8.tgz", - "integrity": "sha512-37okeZyRdVkGrU6nyKBPGqQvyl/7CEiMpUVOb0+BCNCIR0VAuBxNgjMb0mnsR1EXm8dqQFfQZOJ5YW/0sBfa1w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/nested-clients": "^3.997.38", - "@aws-sdk/token-providers": "3.1098.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1098.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1098.0.tgz", - "integrity": "sha512-6cFAviffeqYGjrXn4FqGbWcTxB3nbpBpQXUm6MvnrWZThJaVBTHHnespHgSUh4yVLfzhipSh9H/XjzkHQd9P4g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/nested-clients": "^3.997.38", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.70.tgz", - "integrity": "sha512-Ps/f9USafScb6CSQTRPNMzdKL5x5XRwDIRgFvnuFWfClGZe7/fI7iCFqXdxKjc9LBxP28E7iUgNloGgfgw406w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/nested-clients": "^3.997.38", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.31.tgz", - "integrity": "sha512-/BRzvkp46mF6eXBL/l9WKPQQfifLlUPaWli6n9/T/WDLUg8he7TCyuNFnk6RvHP5j5W/kMj5Gxw7W778LJaXDA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.26.tgz", - "integrity": "sha512-2eIvouTZoxPu5ClHY6ij13De1yhY8Rmllt0dlGeBNXX3wmR7fU1pvMCGb50fKm1GxcuntWK0t1T0cMjTyDoUQA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.46.tgz", - "integrity": "sha512-4ZWFqfiqSTda+SsMUihhype6L0sHWKnLouWj2WYRrF5oC+3G+yUatBILLiesELF+7AP+kHMhEpD2UGnk2vOPMw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.38.tgz", - "integrity": "sha512-jQDs+nxnmgo4+WPOmY373HIC8jx4KyYYMYDI74FWrzX3Ba9fYsV0dSA+7PNYNkhRbbRCRLre83tQ31F1ACCHkA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.3", - "@aws-sdk/signature-v4-multi-region": "^3.996.43", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { - "version": "4.9.13", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", - "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", - "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", - "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", - "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", - "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -853,6 +394,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1055,151 +597,6 @@ "react": ">=16.8.0" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.83.0", - "resolved": "file:../pi/packages/agent", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.83.0", - "diff": "8.0.4", - "ignore": "7.0.5", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-agent-core/node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@earendil-works/pi-agent-core/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.83.0", - "resolved": "file:../pi/packages/ai", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.3.7" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.83.0", - "resolved": "file:../pi/packages/coding-agent", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.83.0", - "@earendil-works/pi-ai": "^0.83.0", - "@earendil-works/pi-tui": "^0.83.0", - "@silvia-odwyer/photon-node": "0.3.4", - "chalk": "5.6.2", - "cross-spawn": "7.0.6", - "diff": "8.0.4", - "glob": "13.0.6", - "highlight.js": "10.7.3", - "hosted-git-info": "9.0.3", - "ignore": "7.0.5", - "jiti": "2.7.0", - "minimatch": "10.2.5", - "proper-lockfile": "4.1.2", - "semver": "7.8.0", - "typebox": "1.3.7", - "undici": "8.5.0", - "yaml": "2.9.0" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "0.3.9" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.83.0", - "resolved": "file:../pi/packages/tui", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -1775,30 +1172,6 @@ "react-dom": "^16 || ^17 || ^18 || ^19" } }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2599,200 +1972,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", - "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.9", - "@mariozechner/clipboard-darwin-universal": "0.3.9", - "@mariozechner/clipboard-darwin-x64": "0.3.9", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-musl": "0.3.9", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", - "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", - "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", - "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", - "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", - "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", - "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", - "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", - "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", - "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", - "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -2872,26 +2051,6 @@ "@chevrotain/types": "~11.1.1" } }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3097,24 +2256,6 @@ "node": ">=12.4.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@pierre/diffs": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.20.tgz", @@ -3260,63 +2401,6 @@ "object-assign": "^4.1.1" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", - "license": "BSD-3-Clause" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -4962,131 +4046,6 @@ "license": "MIT", "peer": true }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, - "node_modules/@smithy/core": { - "version": "3.31.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", - "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", - "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", - "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.6.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", - "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@splinetool/runtime": { "version": "0.9.526", "resolved": "https://registry.npmjs.org/@splinetool/runtime/-/runtime-0.9.526.tgz", @@ -5850,6 +4809,7 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -5899,12 +4859,6 @@ "@types/react": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6471,16 +5425,6 @@ "react": ">= 16.8.0" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6504,15 +5448,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ahooks": { "version": "3.9.7", "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.7.tgz", @@ -6934,31 +5869,12 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", @@ -6971,32 +5887,11 @@ "node": ">=6.0.0" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7052,12 +5947,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7346,13 +6235,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -7394,6 +6276,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7969,15 +6852,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8043,6 +6917,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8179,13 +7054,6 @@ "node": ">=0.3.1" } }, - "node_modules/dingbat-to-unicode": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", - "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -8209,16 +7077,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/duck": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", - "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", - "dev": true, - "license": "BSD", - "dependencies": { - "underscore": "^1.13.1" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8234,15 +7092,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -9198,6 +8047,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, "license": "MIT" }, "node_modules/extend-shallow": { @@ -9300,29 +8150,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -9458,18 +8285,6 @@ "node": ">=0.4.x" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -9540,34 +8355,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", - "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -9592,7 +8379,9 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -9692,23 +8481,6 @@ "lit": "^3.2.1" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9752,32 +8524,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/google-auth-library": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", - "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9795,6 +8541,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/hachure-fill": { @@ -10240,6 +8987,7 @@ "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -10262,18 +9010,6 @@ "react-is": "^16.7.0" } }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -10296,32 +9032,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -10345,13 +9055,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true, - "license": "MIT" - }, "node_modules/immer": { "version": "11.1.4", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", @@ -10391,13 +9094,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -10968,6 +9664,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -11003,6 +9700,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -11049,15 +9747,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11072,19 +9761,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11132,40 +9808,6 @@ "node": ">=4.0" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -11265,16 +9907,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -11618,12 +10250,6 @@ "dev": true, "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -11648,18 +10274,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lop": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", - "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "duck": "^0.1.12", - "option": "~0.2.1", - "underscore": "^1.13.1" - } - }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -11683,15 +10297,6 @@ "license": "MIT", "peer": true }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/lucide-react": { "version": "0.562.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", @@ -11713,41 +10318,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@xmldom/xmldom": "^0.8.6", - "argparse": "~1.0.3", - "base64-js": "^1.5.1", - "bluebird": "~3.4.0", - "dingbat-to-unicode": "^1.0.1", - "jszip": "^3.7.1", - "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", - "underscore": "^1.13.1", - "xmlbuilder": "^10.0.0" - }, - "bin": { - "mammoth": "bin/mammoth" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/mammoth/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -11773,18 +10343,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13077,6 +11635,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -13098,15 +11657,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mixin-deep": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", @@ -13186,6 +11736,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -13310,26 +11861,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -13349,24 +11880,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -13543,34 +12056,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/option": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", - "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13639,19 +12124,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -13659,13 +12131,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13725,12 +12190,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -13748,20 +12207,11 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13774,22 +12224,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13928,13 +12362,6 @@ "node": ">=6" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -13947,26 +12374,6 @@ "react-is": "^16.13.1" } }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -13978,29 +12385,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -14580,36 +12964,6 @@ "react-dom": "*" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -15089,15 +13443,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -15180,26 +13525,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -15368,13 +13693,6 @@ "node": ">=0.10.0" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -15437,6 +13755,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15449,6 +13768,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15607,12 +13927,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -15676,13 +13990,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -15704,23 +14011,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -16119,12 +14409,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -16191,12 +14475,6 @@ "node": ">= 0.8.0" } }, - "node_modules/typebox": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", - "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", - "license": "MIT" - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -16339,26 +14617,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -16607,13 +14870,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/v8n": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", @@ -16710,19 +14966,11 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -16833,37 +15081,6 @@ "node": ">=0.10.0" } }, - "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xmlbuilder": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", - "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -16871,21 +15088,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -16903,20 +15105,12 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/pi-web/package.json b/pi-web/package.json index ac4c527..a46b491 100644 --- a/pi-web/package.json +++ b/pi-web/package.json @@ -38,14 +38,9 @@ "release": "npm version patch --no-git-tag-version && npm run build && npm publish --access public" }, "dependencies": { - "@earendil-works/pi-agent-core": "file:../pi/packages/agent", - "@earendil-works/pi-ai": "file:../pi/packages/ai", - "@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent", - "@earendil-works/pi-tui": "file:../pi/packages/tui", "next": "16.2.12", "react": "^19.2.4", - "react-dom": "^19.2.4", - "undici": "8.5.0" + "react-dom": "^19.2.4" }, "devDependencies": { "@lobehub/icons": "^5.6.0", @@ -56,9 +51,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", "eslint-config-next": "16.2.12", - "jiti": "^2.7.0", "katex": "^0.16.47", - "mammoth": "^1.12.0", "mermaid": "^11.14.0", "postcss": "^8", "react-markdown": "^10.1.0", diff --git a/pi-web/proxy.ts b/pi-web/proxy.ts deleted file mode 100644 index 187da76..0000000 --- a/pi-web/proxy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { isApiRequestAllowed } from "@/lib/request-security"; - -export function proxy(request: NextRequest) { - if (!isApiRequestAllowed(request)) { - return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); - } - return NextResponse.next(); -} - -export const config = { matcher: "/api/:path*" }; diff --git a/scripts/check-integration.mjs b/scripts/check-integration.mjs index 850a75c..67378ff 100644 --- a/scripts/check-integration.mjs +++ b/scripts/check-integration.mjs @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); -const webDir = join(rootDir, "pi-web"); +const serverDir = join(rootDir, "server"); const requiredProfilePaths = [ join(rootDir, "config", "settings.default.json"), join(rootDir, "config", "mcp.default.json"), @@ -37,7 +37,7 @@ function readJson(path) { } } -const webPackage = readJson(join(webDir, "package.json")); +const serverPackage = readJson(join(serverDir, "package.json")); const failures = []; const versions = new Set(); @@ -47,13 +47,13 @@ for (const path of requiredProfilePaths) { for (const [packageName, packageDirName] of packages) { const localDir = resolve(rootDir, "pi", "packages", packageDirName); - const installedDir = resolve(webDir, "node_modules", ...packageName.split("/")); + const installedDir = resolve(serverDir, "node_modules", ...packageName.split("/")); const localPackagePath = join(localDir, "package.json"); const installedPackagePath = join(installedDir, "package.json"); const expectedSpec = `file:../pi/packages/${packageDirName}`; - if (webPackage.dependencies?.[packageName] !== expectedSpec) { - failures.push(`${packageName} must use ${expectedSpec}`); + if (serverPackage.dependencies?.[packageName] !== expectedSpec) { + failures.push(`${packageName} must use ${expectedSpec} in server/package.json`); continue; } if (!existsSync(localPackagePath)) { @@ -93,4 +93,4 @@ if (failures.length > 0) { process.exit(1); } -console.log("Local Pi backend packages are built and installed into Pi Web."); +console.log("Local Pi backend packages are built and installed into Pi Agent Server."); diff --git a/scripts/check-managed-profile.mjs b/scripts/check-managed-profile.mjs index 59328b4..b0814c2 100644 --- a/scripts/check-managed-profile.mjs +++ b/scripts/check-managed-profile.mjs @@ -14,7 +14,7 @@ import { Object.assign(process.env, managedEnvironment()); const codingAgentEntry = pathToFileURL( - join(rootDir, "pi-web", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"), + join(rootDir, "server", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"), ).href; const { DefaultResourceLoader } = await import(codingAgentEntry); diff --git a/scripts/configure-pi-ai-vision.mjs b/scripts/configure-pi-ai-vision.mjs index 680cd77..fb6572a 100644 --- a/scripts/configure-pi-ai-vision.mjs +++ b/scripts/configure-pi-ai-vision.mjs @@ -64,15 +64,16 @@ export function isPiAiVisionConfigured(source) { export function defaultPiAiDir() { // Managed installs place packages under /npm/node_modules; pi-ai is - // also a direct dependency of pi-web, which wins in dev. Prefer pi-web's copy. + // also a direct dependency of the backend (server/), which wins at runtime. + // Prefer the server's copy. const candidates = [ - resolve(process.cwd(), "pi-web", "node_modules", "@earendil-works", "pi-ai"), + resolve(process.cwd(), "server", "node_modules", "@earendil-works", "pi-ai"), resolve(process.cwd(), "node_modules", "@earendil-works", "pi-ai"), ]; for (const candidate of candidates) { if (existsSync(join(candidate, "package.json"))) return candidate; } - throw new Error("pi-ai package not found under pi-web/node_modules or node_modules"); + throw new Error("pi-ai package not found under server/node_modules or node_modules"); } export function configurePiAiVision({ piAiDir = defaultPiAiDir(), quiet = false } = {}) { diff --git a/scripts/profile.mjs b/scripts/profile.mjs index 86c20d2..66ab24f 100644 --- a/scripts/profile.mjs +++ b/scripts/profile.mjs @@ -55,10 +55,10 @@ const seedFiles = [ ]; const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"]; -const piWebBinDir = join(rootDir, "pi-web", "node_modules", ".bin"); +const serverBinDir = join(rootDir, "server", "node_modules", ".bin"); const piCodingAgentPackageDir = join( rootDir, - "pi-web", + "server", "node_modules", "@earendil-works", "pi-coding-agent", @@ -73,7 +73,7 @@ const managedPeerPackageDir = join( function ensurePiSubagentRuntime() { // pi-subagents resolves the host CLI through its optional coding-agent peer. - // The managed profile is installed separately from pi-web, so expose the + // The managed profile is installed separately from the backend, so expose the // existing local package instead of installing a second coding-agent copy. if (!existsSync(piCodingAgentPackageDir)) return; @@ -97,12 +97,12 @@ function ensurePiSubagentRuntime() { } } -function prependPiWebBinToPath(baseEnv) { - if (!existsSync(piWebBinDir)) return baseEnv.PATH ?? baseEnv.Path; - const pathKey = baseEnv.PATH !== undefined || baseEnv.Path === undefined ? "PATH" : "Path"; +function prependServerBinToPath(baseEnv) { + if (!existsSync(serverBinDir)) return baseEnv.PATH ?? baseEnv.Path; + const pathKey = baseEnv.PATH !== undefined && baseEnv.Path === undefined ? "PATH" : "Path"; const currentPath = baseEnv[pathKey] ?? ""; const entries = currentPath.split(delimiter).filter(Boolean); - if (!entries.includes(piWebBinDir)) entries.unshift(piWebBinDir); + if (!entries.includes(serverBinDir)) entries.unshift(serverBinDir); return entries.join(delimiter); } @@ -167,7 +167,7 @@ export function managedEnvironment(baseEnv = process.env) { return { ...baseEnv, ...persistedEnvironment, - PATH: prependPiWebBinToPath({ ...baseEnv, ...persistedEnvironment }), + PATH: prependServerBinToPath({ ...baseEnv, ...persistedEnvironment }), PI_AGENT_MANAGED_RUNTIME: "1", PI_AGENT_APP_ROOT: rootDir, PI_AGENT_DATA_DIR: dataDir, diff --git a/scripts/run.mjs b/scripts/run.mjs index b5f4c0b..38a29eb 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { platform } from "node:os"; import { join } from "node:path"; import { configurePiMemory } from "./configure-pi-memory.mjs"; import { configurePiAiVision } from "./configure-pi-ai-vision.mjs"; @@ -23,6 +24,7 @@ if (!npmCliPath) { } const childEnvironment = managedEnvironment(); +const serverPort = Number(childEnvironment.PI_SERVER_PORT ?? "30142"); const memoryConfiguration = configurePiMemory({ quiet: true }); if (memoryConfiguration.status === "missing") { console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory"); @@ -38,28 +40,135 @@ if (await isLocalPortListening(30141)) { printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment })); } -const child = spawn( - process.execPath, - [npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target], - { - cwd: rootDir, - env: childEnvironment, - stdio: "inherit", - }, -); +const children = []; +let shuttingDown = false; -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); +function spawnPackageScript(packageDir, script) { + return spawn( + process.execPath, + [npmCliPath, "--prefix", join(rootDir, packageDir), "run", script], + { + cwd: rootDir, + env: childEnvironment, + stdio: "inherit", + }, + ); } -child.on("error", (error) => { - console.error(error); - process.exit(1); -}); -child.on("exit", (code, signal) => { - if (!signal && code === 0 && target === "build") { - printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment })); +function killChild(child, signal) { + if (child.killed || child.exitCode !== null) return; + if (platform() === "win32") { + // Windows has no process groups: killing the npm wrapper leaves the + // real workers (tsx watch / next dev) orphaned and holding their ports. + // taskkill /T terminates the whole tree instead. + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }); + } else { + child.kill(signal); } - if (signal) process.kill(process.pid, signal); - process.exit(code ?? 1); -}); +} + +function stopAll(signal) { + shuttingDown = true; + for (const child of children) { + killChild(child, signal); + } +} + +// TCP listening alone is not enough: an orphaned backend from a previous run +// can hold the port. Verify identity through the health endpoint instead. +async function isBackendHealthy() { + try { + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, { + signal: AbortSignal.timeout(750), + }); + if (!response.ok) return false; + const body = await response.json(); + return body?.name === "pi-agent-server"; + } catch { + return false; + } +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => stopAll(signal)); +} + +if (target === "build") { + // Sequential builds: the backend is independent, the frontend only needs + // the shared types package, so order matters only for readable logs. + const serverBuild = spawnPackageScript("server", "build"); + serverBuild.on("error", (error) => { + console.error(error); + process.exit(1); + }); + serverBuild.on("exit", (code, signal) => { + if (signal || code !== 0) process.exit(code ?? 1); + const webBuild = spawnPackageScript("pi-web", "build"); + webBuild.on("error", (error) => { + console.error(error); + process.exit(1); + }); + webBuild.on("exit", (code, signal) => { + if (!signal && code === 0) { + printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment })); + } + if (signal) process.kill(process.pid, signal); + process.exit(code ?? 1); + }); + }); +} else { + // Backend first: agent sessions live in pi-agent-server, and Pi Web's + // /api rewrites need it listening before the first request arrives. + if (await isLocalPortListening(serverPort)) { + console.error( + `[run] port ${serverPort} is already in use (possibly an orphaned backend). ` + + `Find it with: netstat -ano | findstr :${serverPort}`, + ); + process.exit(1); + } + const serverScript = target.startsWith("dev") ? "dev" : "start"; + const server = spawnPackageScript("server", serverScript); + children.push(server); + server.on("error", (error) => { + console.error(error); + process.exit(1); + }); + server.on("exit", (code, signal) => { + if (shuttingDown) return; + console.error(`[run] backend exited (${code ?? signal}); shutting down Pi Web`); + stopAll(signal ?? "SIGTERM"); + if (signal) process.kill(process.pid, signal); + process.exit(code ?? 1); + }); + + const startWeb = () => { + const web = spawnPackageScript("pi-web", target); + children.push(web); + web.on("error", (error) => { + console.error(error); + process.exit(1); + }); + web.on("exit", (code, signal) => { + stopAll(signal ?? "SIGTERM"); + if (signal) process.kill(process.pid, signal); + process.exit(code ?? 1); + }); + }; + + let webStarted = false; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (server.exitCode !== null || server.killed) break; + if (await isBackendHealthy()) { + webStarted = true; + startWeb(); + break; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 250)); + } + if (!webStarted) { + console.error(`[run] backend did not become ready on port ${serverPort} within 30s`); + stopAll("SIGTERM"); + process.exit(1); + } +} diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 237ac56..a4558fd 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -91,17 +91,20 @@ function assertSupportedNodeVersion() { assertSupportedNodeVersion(); ensureProfile(); -console.log("[1/5] Installing Pi dependencies..."); +console.log("[1/6] Installing Pi dependencies..."); run(["ci", "--ignore-scripts"], join(rootDir, "pi")); -console.log("[2/5] Hydrating model data and building local Pi packages..."); +console.log("[2/6] Hydrating model data and building local Pi packages..."); hydrateModelData(); run(["run", "build:offline"], join(rootDir, "pi")); -console.log("[3/5] Installing Pi Web with local Pi package links..."); -run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "pi-web")); +console.log("[3/6] Installing Pi Web dependencies..."); +run(["ci", "--ignore-scripts"], join(rootDir, "pi-web")); -console.log("[4/5] Installing and validating managed Pi packages..."); +console.log("[4/6] Installing Pi Agent Server with local Pi package links..."); +run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "server")); + +console.log("[5/6] Installing and validating managed Pi packages..."); const packageResult = spawnSync(process.execPath, [join(rootDir, "scripts", "install-managed-packages.mjs")], { cwd: rootDir, stdio: "inherit", @@ -118,7 +121,7 @@ const profileResult = spawnSync(process.execPath, [join(rootDir, "scripts", "che if (profileResult.error) throw profileResult.error; if (profileResult.status !== 0) process.exit(profileResult.status ?? 1); -console.log("[5/5] Verifying the integration..."); +console.log("[6/6] Verifying the integration..."); const checkResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-integration.mjs")], { cwd: rootDir, stdio: "inherit", diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..23f9d9e --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2565 @@ +{ + "name": "pi-agent-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-agent-server", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "file:../pi/packages/agent", + "@earendil-works/pi-ai": "file:../pi/packages/ai", + "@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent", + "@earendil-works/pi-tui": "file:../pi/packages/tui", + "@hono/node-server": "^1.19.3", + "hono": "^4.11.4", + "mammoth": "^1.12.0" + }, + "devDependencies": { + "@types/node": "^25", + "jiti": "^2.7.0", + "tsx": "^4.20.3", + "typescript": "^5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.31.tgz", + "integrity": "sha512-/BRzvkp46mF6eXBL/l9WKPQQfifLlUPaWli6n9/T/WDLUg8he7TCyuNFnk6RvHP5j5W/kMj5Gxw7W778LJaXDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.26.tgz", + "integrity": "sha512-2eIvouTZoxPu5ClHY6ij13De1yhY8Rmllt0dlGeBNXX3wmR7fU1pvMCGb50fKm1GxcuntWK0t1T0cMjTyDoUQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.49.tgz", + "integrity": "sha512-wGYJvu1/stdWuzGcNyh44u8aY7ArU8XFrMesBlzIYn70HWVCRBNEngQexDuPIMu0NEgsKGKmTc0uvnS/bF7KWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.83.0", + "resolved": "file:../pi/packages/agent", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.83.0", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.83.0", + "resolved": "file:../pi/packages/ai", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.83.0", + "resolved": "file:../pi/packages/coding-agent", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.83.0", + "@earendil-works/pi-ai": "^0.83.0", + "@earendil-works/pi-tui": "^0.83.0", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.83.0", + "resolved": "file:../pi/packages/tui", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..217ba8a --- /dev/null +++ b/server/package.json @@ -0,0 +1,32 @@ +{ + "name": "pi-agent-server", + "version": "0.1.0", + "private": true, + "description": "Standalone backend for Pi Agent Integrated", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "tsx src/index.ts", + "typecheck": "tsc" + }, + "dependencies": { + "@earendil-works/pi-agent-core": "file:../pi/packages/agent", + "@earendil-works/pi-ai": "file:../pi/packages/ai", + "@earendil-works/pi-coding-agent": "file:../pi/packages/coding-agent", + "@earendil-works/pi-tui": "file:../pi/packages/tui", + "@hono/node-server": "^1.19.3", + "hono": "^4.11.4", + "mammoth": "^1.12.0" + }, + "devDependencies": { + "@types/node": "^25", + "jiti": "^2.7.0", + "tsx": "^4.20.3", + "typescript": "^5" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..0e12013 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,67 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { registerAgentRoutes } from "./routes/agent.js"; +import { registerAuthRoutes } from "./routes/auth.js"; +import { registerConfigRoutes } from "./routes/config.js"; +import { registerFileRoutes } from "./routes/files.js"; +import { registerGitAndModelRoutes } from "./routes/models.js"; +import { registerMiscRoutes } from "./routes/misc.js"; +import { registerSessionRoutes } from "./routes/sessions.js"; +import { registerSkillRoutes } from "./routes/skills.js"; +import { securityMiddleware } from "./security.js"; + +const serverDir = dirname(dirname(fileURLToPath(import.meta.url))); +const { version } = JSON.parse(readFileSync(join(serverDir, "package.json"), "utf8")) as { version: string }; + +const startedAt = Date.now(); + +export const app = new Hono(); + +app.use("/api/*", securityMiddleware); + +// Liveness probe used by scripts/run.mjs to sequence startup and by +// npm run check to verify the backend is reachable. +app.get("/api/health", (c) => + c.json({ + status: "ok", + name: "pi-agent-server", + version, + uptimeMs: Date.now() - startedAt, + }), +); + +registerAgentRoutes(app); +registerSessionRoutes(app); +registerFileRoutes(app); +registerGitAndModelRoutes(app); +registerMiscRoutes(app); +registerAuthRoutes(app); +registerConfigRoutes(app); +registerSkillRoutes(app); + +export function startServer(): ReturnType { + const port = Number(process.env.PI_SERVER_PORT ?? "30142"); + // The frontend proxies /api here server-side, so the backend stays on + // loopback even in LAN mode unless the operator overrides explicitly. + const hostname = process.env.PI_SERVER_HOSTNAME ?? "127.0.0.1"; + + const server = serve({ fetch: app.fetch, port, hostname }, (info) => { + console.log(`[server] pi-agent-server ${version} listening on http://${info.address}:${info.port}`); + }); + + const shutdown = (signal: string) => { + console.log(`[server] received ${signal}, shutting down`); + server.close(() => process.exit(0)); + // Force-exit if lingering connections (e.g. open SSE streams) block close. + setTimeout(() => process.exit(0), 3000).unref(); + }; + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + + return server; +} + +startServer(); diff --git a/pi-web/lib/allowed-roots.ts b/server/src/lib/allowed-roots.ts similarity index 100% rename from pi-web/lib/allowed-roots.ts rename to server/src/lib/allowed-roots.ts diff --git a/server/src/lib/api-types.ts b/server/src/lib/api-types.ts new file mode 100644 index 0000000..361388c --- /dev/null +++ b/server/src/lib/api-types.ts @@ -0,0 +1,123 @@ +import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; + +export interface SkillSearchResult { + package: string; + installs: string; + url: string; +} + +export type SkillInstallScope = "global" | "project"; + +export interface SkillInstallInfo { + package: string; + scope: SkillInstallScope; + source: string; + sourceType?: string; + skillsShUrl?: string; + skillPath?: string; + ref?: string; + versionHash?: string; + canCheckForUpdates: boolean; +} + +export type SkillUpdateState = + | "up-to-date" + | "update-available" + | "unsupported" + | "error"; + +export interface SkillUpdateResult { + package: string; + scope: SkillInstallScope; + state: SkillUpdateState; + currentVersion?: string; + latestVersion?: string; + message?: string; +} + +export interface SkillInfo { + name: string; + description: string; + filePath: string; + baseDir: string; + disableModelInvocation: boolean; + sourceInfo: { + source?: string; + scope?: string; + }; + install?: SkillInstallInfo; +} + +export interface SkillsResponse { + skills: SkillInfo[]; + diagnostics: ResourceDiagnostic[]; + projectResourcesLoaded: boolean; +} + +export interface ProjectTrustStatus { + requiresTrust: boolean; + trusted: boolean; +} + +export type PluginScope = "global" | "project"; +export type PluginResourceKind = "extension" | "skill" | "prompt" | "theme"; + +export interface PluginResourceCounts { + extensions: number; + skills: number; + prompts: number; + themes: number; +} + +export interface PluginDiagnostic { + type: "warning" | "error"; + message: string; + source?: string; + path?: string; +} + +export interface PluginResourceInfo { + kind: PluginResourceKind; + name: string; + path: string; + relativePath: string; +} + +export interface PluginPackageInfo { + source: string; + scope: PluginScope; + filtered: boolean; + disabled: boolean; + installedPath?: string; + packageName?: string; + version?: string; + configuredVersion?: string; + counts: PluginResourceCounts; + resources: PluginResourceInfo[]; + status: "loaded" | "installed" | "missing" | "disabled"; +} + +export interface PluginsResponse { + packages: PluginPackageInfo[]; + totals: PluginResourceCounts; + diagnostics: PluginDiagnostic[]; + projectResourcesLoaded: boolean; +} + +export type ExtensionScope = "global" | "project" | "builtin"; +export type ExtensionStatus = "enabled" | "disabled" | "blocked"; + +export interface ExtensionInfo { + name: string; + path: string; + relativePath: string; + source: string; + scope: ExtensionScope; + status: ExtensionStatus; +} + +export interface ExtensionsResponse { + extensions: ExtensionInfo[]; + diagnostics: PluginDiagnostic[]; + projectResourcesLoaded: boolean; +} diff --git a/pi-web/lib/app-runtime.test.mjs b/server/src/lib/app-runtime.test.mjs similarity index 98% rename from pi-web/lib/app-runtime.test.mjs rename to server/src/lib/app-runtime.test.mjs index 0241cef..65cec86 100644 --- a/pi-web/lib/app-runtime.test.mjs +++ b/server/src/lib/app-runtime.test.mjs @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/pi-web/lib/app-runtime.ts b/server/src/lib/app-runtime.ts similarity index 100% rename from pi-web/lib/app-runtime.ts rename to server/src/lib/app-runtime.ts diff --git a/pi-web/lib/bash-output.test.mjs b/server/src/lib/bash-output.test.mjs similarity index 99% rename from pi-web/lib/bash-output.test.mjs rename to server/src/lib/bash-output.test.mjs index 93d79b8..b24581a 100644 --- a/pi-web/lib/bash-output.test.mjs +++ b/server/src/lib/bash-output.test.mjs @@ -39,7 +39,6 @@ test("reads small output and rejects oversized inline output before buffering it await rm(dir, { recursive: true, force: true }); } }); - test("rejects symbolic links when opening bash output", async (t) => { const { readUtf8FileWithinLimit } = await loadSubject(); const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-link-")); diff --git a/pi-web/lib/bash-output.ts b/server/src/lib/bash-output.ts similarity index 100% rename from pi-web/lib/bash-output.ts rename to server/src/lib/bash-output.ts diff --git a/pi-web/lib/bounded-form-data.test.mjs b/server/src/lib/bounded-form-data.test.mjs similarity index 100% rename from pi-web/lib/bounded-form-data.test.mjs rename to server/src/lib/bounded-form-data.test.mjs diff --git a/pi-web/lib/bounded-form-data.ts b/server/src/lib/bounded-form-data.ts similarity index 100% rename from pi-web/lib/bounded-form-data.ts rename to server/src/lib/bounded-form-data.ts diff --git a/pi-web/lib/custom-ui-terminal.test.mjs b/server/src/lib/custom-ui-terminal.test.mjs similarity index 100% rename from pi-web/lib/custom-ui-terminal.test.mjs rename to server/src/lib/custom-ui-terminal.test.mjs diff --git a/pi-web/lib/custom-ui-terminal.ts b/server/src/lib/custom-ui-terminal.ts similarity index 100% rename from pi-web/lib/custom-ui-terminal.ts rename to server/src/lib/custom-ui-terminal.ts diff --git a/pi-web/lib/directory-browser.test.mjs b/server/src/lib/directory-browser.test.mjs similarity index 76% rename from pi-web/lib/directory-browser.test.mjs rename to server/src/lib/directory-browser.test.mjs index 46a4d71..8a17e10 100644 --- a/pi-web/lib/directory-browser.test.mjs +++ b/server/src/lib/directory-browser.test.mjs @@ -8,17 +8,27 @@ async function loadSubject() { return import("./directory-browser.ts"); } -test("lists directories and directory symlinks without returning files", async () => { +test("lists directories and directory symlinks without returning files", async (t) => { const root = await mkdtemp(path.join(tmpdir(), "pi-web-browse-")); try { await mkdir(path.join(root, "project")); await writeFile(path.join(root, "notes.txt"), "test", "utf8"); - await symlink(path.join(root, "project"), path.join(root, "linked-project")); + let symlinkCreated = false; + try { + await symlink(path.join(root, "project"), path.join(root, "linked-project")); + symlinkCreated = true; + } catch (error) { + if (error?.code !== "EPERM") throw error; + t.diagnostic("Creating symbolic links requires additional privileges on this platform"); + } const { listDirectories } = await loadSubject(); const directories = await listDirectories(root); - assert.deepEqual(directories.map((entry) => entry.name), ["linked-project", "project"]); + assert.deepEqual( + directories.map((entry) => entry.name), + symlinkCreated ? ["linked-project", "project"] : ["project"], + ); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/pi-web/lib/directory-browser.ts b/server/src/lib/directory-browser.ts similarity index 96% rename from pi-web/lib/directory-browser.ts rename to server/src/lib/directory-browser.ts index c8bceee..eef08c4 100644 --- a/pi-web/lib/directory-browser.ts +++ b/server/src/lib/directory-browser.ts @@ -20,7 +20,9 @@ export function normalizeDirectory(directory: string): string { export function getParentDirectory(directory: string): string | null { const pathApi = /^[a-zA-Z]:[\\/]/.test(directory) || directory.startsWith("\\\\") ? path.win32 - : path; + : directory.startsWith("/") + ? path.posix + : path; const normalized = pathApi.normalize(directory); const parent = pathApi.dirname(normalized); return parent === normalized ? null : parent; diff --git a/pi-web/lib/file-access.test.mjs b/server/src/lib/file-access.test.mjs similarity index 100% rename from pi-web/lib/file-access.test.mjs rename to server/src/lib/file-access.test.mjs diff --git a/pi-web/lib/file-access.ts b/server/src/lib/file-access.ts similarity index 100% rename from pi-web/lib/file-access.ts rename to server/src/lib/file-access.ts diff --git a/pi-web/lib/file-dirent.test.mjs b/server/src/lib/file-dirent.test.mjs similarity index 100% rename from pi-web/lib/file-dirent.test.mjs rename to server/src/lib/file-dirent.test.mjs diff --git a/pi-web/lib/file-dirent.ts b/server/src/lib/file-dirent.ts similarity index 100% rename from pi-web/lib/file-dirent.ts rename to server/src/lib/file-dirent.ts diff --git a/server/src/lib/file-fuzzy.ts b/server/src/lib/file-fuzzy.ts new file mode 100644 index 0000000..75ad7cc --- /dev/null +++ b/server/src/lib/file-fuzzy.ts @@ -0,0 +1,189 @@ +// Pure helpers for the chat input's @ file autocomplete. Mirrors the pi TUI's +// behavior: @ triggers at line start or after whitespace, entries are ranked +// with the TUI's scoreEntry ladder, and completions insert "@relative/path ". + +export interface AtQueryMatch { + /** Index of the "@" character in the text */ + start: number; + /** Text typed after the "@" (quotes stripped); may be empty */ + query: string; + /** True when the token uses the @"..." quoted form */ + quoted: boolean; +} + +export interface FileIndexEntry { + /** Path relative to the session cwd, "/"-separated, no trailing slash */ + path: string; + isDir: boolean; +} + +/** + * Detect an @ file token immediately before the cursor. The @ must be at the + * start of the text or preceded by whitespace (same rule as the TUI), so + * emails like foo@bar never trigger. Supports the in-progress quoted form + * @"my dir/fi so drill-down into space-containing paths keeps working. + */ +export function extractAtQuery(textBeforeCursor: string): AtQueryMatch | null { + const quoted = /(?:^|\s)@"([^"\n]*)$/.exec(textBeforeCursor); + if (quoted) { + return { + start: textBeforeCursor.length - (quoted[1].length + 2), + query: quoted[1], + quoted: true, + }; + } + const plain = /(?:^|\s)@([^\s"]*)$/.exec(textBeforeCursor); + if (plain) { + return { + start: textBeforeCursor.length - (plain[1].length + 1), + query: plain[1], + quoted: false, + }; + } + return null; +} + +function pathDepth(p: string): number { + let depth = 0; + for (let i = 0; i < p.length; i++) { + if (p[i] === "/") depth++; + } + return depth; +} + +/** + * Build the entry list from the server's flat file list, deriving directory + * entries from file paths (the index API only returns files). Base order is + * shallow-first then alphabetical, which is what an empty @ query shows. + */ +export function buildEntriesFromFiles(files: string[]): FileIndexEntry[] { + const dirs = new Set(); + for (const f of files) { + let idx = f.indexOf("/"); + while (idx !== -1) { + dirs.add(f.slice(0, idx)); + idx = f.indexOf("/", idx + 1); + } + } + const entries: FileIndexEntry[] = []; + for (const d of dirs) entries.push({ path: d, isDir: true }); + for (const f of files) { + if (!f) continue; + entries.push({ path: f, isDir: false }); + } + entries.sort((a, b) => pathDepth(a.path) - pathDepth(b.path) || a.path.localeCompare(b.path)); + return entries; +} + +function isSubsequence(needle: string, haystack: string): boolean { + if (!needle) return true; + let i = 0; + for (let j = 0; j < haystack.length && i < needle.length; j++) { + if (haystack[j] === needle[i]) i++; + } + return i === needle.length; +} + +/** + * TUI scoreEntry ladder (exact 100 / prefix 80 / substring 50 / path substring + * 30, directories +10) plus a low-weight subsequence fallback so genuinely + * fuzzy queries like "chinp" still find components/ChatInput.tsx. + * + * Queries containing "/" are ranked against the full relative path instead of + * the basename — this is what makes drill-down work: after inserting "@src/", + * the query "src/" prefix-matches every entry inside src/ (and excludes the + * src directory itself, since "src" does not start with "src/"). + */ +function scoreEntry(entry: FileIndexEntry, lowerQuery: string): number { + const lowerPath = entry.path.toLowerCase(); + let score = 0; + if (lowerQuery.includes("/")) { + if (lowerPath === lowerQuery) score = 100; + else if (lowerPath.startsWith(lowerQuery)) score = 80; + else if (lowerPath.includes(lowerQuery)) score = 50; + else if (isSubsequence(lowerQuery, lowerPath)) score = 10; + } else { + const slash = lowerPath.lastIndexOf("/"); + const lowerName = slash === -1 ? lowerPath : lowerPath.slice(slash + 1); + if (lowerName === lowerQuery) score = 100; + else if (lowerName.startsWith(lowerQuery)) score = 80; + else if (lowerName.includes(lowerQuery)) score = 50; + else if (lowerPath.includes(lowerQuery)) score = 30; + else if (isSubsequence(lowerQuery, lowerPath)) score = 10; + } + if (entry.isDir && score > 0) score += 10; + return score; +} + +export const AT_RESULT_LIMIT = 20; + +export function filterFileEntries( + entries: FileIndexEntry[], + query: string, + limit: number = AT_RESULT_LIMIT, +): FileIndexEntry[] { + const lowerQuery = query.toLowerCase(); + if (!lowerQuery) return entries.slice(0, limit); + + const scored: Array<{ entry: FileIndexEntry; score: number }> = []; + for (const entry of entries) { + const score = scoreEntry(entry, lowerQuery); + if (score > 0) scored.push({ entry, score }); + } + scored.sort((a, b) => + b.score - a.score + || pathDepth(a.entry.path) - pathDepth(b.entry.path) + || a.entry.path.localeCompare(b.entry.path)); + return scored.slice(0, limit).map((s) => s.entry); +} + +export interface AtInsertion { + /** Text that replaces the @token */ + text: string; + /** Caret position relative to the start of `text` after insertion */ + cursorOffset: number; +} + +/** + * Replacement for the @token when a suggestion is confirmed. Mirrors the + * TUI's buildCompletionValue/applyCompletion: + * - Files close the token: "@path " (quoted when the path contains spaces), + * caret after the trailing space. + * - Directories keep the menu open for drill-down: "@dir/" with no trailing + * space. Quoted directories are inserted CLOSED (@"my dir/") with the caret + * placed before the closing quote, so both further typing and manual + * completion keep the token well-formed. + */ +export function buildAtInsertText(entryPath: string, isDir: boolean, forceQuotes = false): AtInsertion { + const p = isDir ? `${entryPath}/` : entryPath; + const needsQuotes = forceQuotes || p.includes(" "); + if (isDir) { + const text = needsQuotes ? `@"${p}"` : `@${p}`; + return { text, cursorOffset: needsQuotes ? text.length - 1 : text.length }; + } + const text = needsQuotes ? `@"${p}" ` : `@${p} `; + return { text, cursorOffset: text.length }; +} + +/** + * Closed @mention for one-shot inserts (e.g. the file explorer's @ button). + * Unlike buildAtInsertText there is no drill-down: directories are closed + * too, with a trailing "/" and a trailing space. + */ +export function buildAtMentionText(entryPath: string, isDir: boolean): string { + const p = isDir ? `${entryPath}/` : entryPath; + return p.includes(" ") ? `@"${p}" ` : `@${p} `; +} + +/** Closed file @mention scoped to one logical line or an inclusive line range. */ +export function buildFileLineMentionText(entryPath: string, startLine: number, endLine: number): string { + const firstLine = Math.max(1, Math.min(startLine, endLine)); + const lastLine = Math.max(1, Math.max(startLine, endLine)); + const pathMention = entryPath.includes(" ") ? `@"${entryPath}"` : `@${entryPath}`; + const lineSuffix = firstLine === lastLine ? `:${firstLine}` : `:${firstLine}-${lastLine}`; + return `${pathMention}${lineSuffix} `; +} + +export function buildFileAtMentionsText(entryPaths: string[]): string { + return entryPaths.map((entryPath) => buildAtMentionText(entryPath, false)).join(""); +} diff --git a/server/src/lib/file-types.ts b/server/src/lib/file-types.ts new file mode 100644 index 0000000..78c2248 --- /dev/null +++ b/server/src/lib/file-types.ts @@ -0,0 +1,73 @@ +export const TEXT_PREVIEW_MAX_BYTES = 256 * 1024; +export const IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024; +export const DOCX_PREVIEW_MAX_BYTES = 10 * 1024 * 1024; + +export type DocumentPreviewKind = "pdf" | "docx"; + +export const IMAGE_EXT_TO_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + ico: "image/x-icon", + avif: "image/avif", +}; + +export const AUDIO_EXT_TO_MIME: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + oga: "audio/ogg", + opus: "audio/ogg", + m4a: "audio/mp4", + aac: "audio/aac", + flac: "audio/flac", + weba: "audio/webm", + webm: "audio/webm", +}; + +export const DOCUMENT_EXT_TO_MIME: Record = { + pdf: "application/pdf", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +}; + +function getBaseName(filePath: string): string { + return filePath.replace(/\\/g, "/").split("/").pop() ?? ""; +} + +export function getFileExt(filePath: string): string { + return getBaseName(filePath).toLowerCase().split(".").pop() ?? ""; +} + +export function getImageMime(filePath: string): string | null { + return IMAGE_EXT_TO_MIME[getFileExt(filePath)] ?? null; +} + +export function getAudioMime(filePath: string): string | null { + return AUDIO_EXT_TO_MIME[getFileExt(filePath)] ?? null; +} + +export function getDocumentMime(filePath: string): string | null { + return DOCUMENT_EXT_TO_MIME[getFileExt(filePath) as DocumentPreviewKind] ?? null; +} + +export function documentPreviewKind(filePath: string): DocumentPreviewKind | null { + const ext = getFileExt(filePath); + if (ext === "pdf" || ext === "docx") return ext; + return null; +} + +export function isImagePath(filePath: string): boolean { + return getImageMime(filePath) !== null; +} + +export function isAudioPath(filePath: string): boolean { + return getAudioMime(filePath) !== null; +} + +export function isDocumentPreviewPath(filePath: string): boolean { + return documentPreviewKind(filePath) !== null; +} diff --git a/pi-web/lib/file-upload.test.mjs b/server/src/lib/file-upload.test.mjs similarity index 100% rename from pi-web/lib/file-upload.test.mjs rename to server/src/lib/file-upload.test.mjs diff --git a/pi-web/lib/file-upload.ts b/server/src/lib/file-upload.ts similarity index 100% rename from pi-web/lib/file-upload.ts rename to server/src/lib/file-upload.ts diff --git a/pi-web/lib/git-changes.test.mjs b/server/src/lib/git-changes.test.mjs similarity index 100% rename from pi-web/lib/git-changes.test.mjs rename to server/src/lib/git-changes.test.mjs diff --git a/pi-web/lib/git-changes.ts b/server/src/lib/git-changes.ts similarity index 100% rename from pi-web/lib/git-changes.ts rename to server/src/lib/git-changes.ts diff --git a/pi-web/lib/git-status.ts b/server/src/lib/git-status.ts similarity index 100% rename from pi-web/lib/git-status.ts rename to server/src/lib/git-status.ts diff --git a/server/src/lib/git-types.ts b/server/src/lib/git-types.ts new file mode 100644 index 0000000..2e833b0 --- /dev/null +++ b/server/src/lib/git-types.ts @@ -0,0 +1,29 @@ +export type GitFileStatusKind = + | "modified" + | "added" + | "deleted" + | "renamed" + | "untracked" + | "conflict"; + +export interface GitFileStatus { + filePath: string; + status: GitFileStatusKind; + code: "M" | "A" | "D" | "R" | "U" | "C"; + indexStatus: string; + worktreeStatus: string; +} + +export interface GitStatusResponse { + isGitRepository: boolean; + repositoryRoot: string | null; + files: GitFileStatus[]; + additions: number; + deletions: number; +} + +export interface GitFileDiffResponse { + supported: boolean; + status?: GitFileStatusKind; + patch?: string; +} diff --git a/server/src/lib/image-attachments.ts b/server/src/lib/image-attachments.ts new file mode 100644 index 0000000..dad22dd --- /dev/null +++ b/server/src/lib/image-attachments.ts @@ -0,0 +1,121 @@ +/// +// compressImageFile below is browser-only (canvas/ImageBitmap) and never runs +// here; the dom reference lets this file stay a verbatim copy of the pi-web +// module without adding "dom" to the whole server program. +export const MAX_ATTACHED_IMAGE_BYTES = 10 * 1024 * 1024; +export const MAX_ATTACHED_IMAGES = 10; + +/** 上传时自动压缩:长边超过此像素的 JPEG 缩到此边长(相机照片主场景,视觉模型输入上限 ~1344px,1600 无损)。 */ +export const MAX_IMAGE_EDGE_PX = 1600; +/** JPEG 重编码质量:避免伪影糊掉边缘/小字。 */ +export const IMAGE_JPEG_QUALITY = 0.85; + +export interface Base64ImageAttachment { + data: string; + mimeType: string; +} + +function isBase64DataChar(code: number): boolean { + return ( + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2b || + code === 0x2f + ); +} + +export function getBase64DecodedByteLength(data: string): number | null { + if (!data || data.length % 4 !== 0) return null; + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + const dataEnd = data.length - padding; + for (let index = 0; index < dataEnd; index += 1) { + if (!isBase64DataChar(data.charCodeAt(index))) return null; + } + for (let index = dataEnd; index < data.length; index += 1) { + if (data[index] !== "=") return null; + } + return (data.length / 4) * 3 - padding; +} + +export function isBase64ImageWithinLimits( + value: unknown, +): value is Base64ImageAttachment { + if (!value || typeof value !== "object") return false; + const image = value as Partial; + if ( + typeof image.data !== "string" || + typeof image.mimeType !== "string" || + !image.mimeType.startsWith("image/") + ) { + return false; + } + const bytes = getBase64DecodedByteLength(image.data); + return bytes !== null && bytes <= MAX_ATTACHED_IMAGE_BYTES; +} + +/** + * 上传前压缩:JPEG 和 PNG 且长边超过 MAX_IMAGE_EDGE_PX 时缩放并统一转 JPEG。 + * PNG 截图(代码编辑器/UI)通常无透明通道,转 JPEG 体积大幅减小。 + * WebP/GIF 原样返回(WebP 已经高效;GIF 可能是动画)。 + * 重编码无收益(更小或失败)时退回原文件。 + */ +export async function compressImageFile(file: File): Promise { + // 仅压缩 JPEG 和 PNG(最常见的截图/照片格式) + if ( + !file.type || + !(file.type.startsWith("image/jpeg") || file.type.startsWith("image/png")) + ) { + return file; + } + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + return file; // 解码失败退化为原文件 + } + try { + const edge = Math.max(bitmap.width, bitmap.height); + if (edge <= MAX_IMAGE_EDGE_PX) return file; // 本就不大,避免无谓重编码 + const scale = MAX_IMAGE_EDGE_PX / edge; + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return file; + ctx.drawImage(bitmap, 0, 0, width, height); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", IMAGE_JPEG_QUALITY), + ); + if (!blob || blob.size >= file.size) return file; // 重编码无收益则用原文件 + return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { + type: "image/jpeg", + }); + } finally { + bitmap.close(); + } +} + +/** Return an API-safe error for prompt, steering, and follow-up image arrays. */ +export function validateAgentImages(value: unknown): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return "images must be an array"; + if (value.length > MAX_ATTACHED_IMAGES) { + return `A message can include at most ${MAX_ATTACHED_IMAGES} images`; + } + for (const image of value) { + if ( + !image || + typeof image !== "object" || + (image as { type?: unknown }).type !== "image" + ) { + return "Each attachment must be an image"; + } + if (!isBase64ImageWithinLimits(image)) { + return `Each image must be valid base64 image data of ${MAX_ATTACHED_IMAGE_BYTES / (1024 * 1024)}MB or smaller`; + } + } + return null; +} diff --git a/server/src/lib/model-catalog.ts b/server/src/lib/model-catalog.ts new file mode 100644 index 0000000..4c2770b --- /dev/null +++ b/server/src/lib/model-catalog.ts @@ -0,0 +1,404 @@ +export interface ModelCatalogCost { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; +} + +export interface ModelCatalogEntry { + key: string; + providerId: string; + providerName: string; + providerBaseUrl?: string; + id: string; + name: string; + reasoning?: boolean; + input?: string[]; + contextWindow?: number; + maxTokens?: number; + cost: ModelCatalogCost; +} + +export interface ModelCatalogPreset { + name?: string; + reasoning?: boolean; + input?: string[]; + contextWindow?: number; + maxTokens?: number; + cost?: ModelCatalogCost; +} + +export type ModelCatalogMatchMethod = "provider" | "base-url" | "consensus" | "none"; + +export type ModelCatalogPriceRecommendation = + | { + status: "reliable"; + method: Exclude; + cost: ModelCatalogCost; + providerId?: string; + providerName?: string; + support: number; + total: number; + } + | { + status: "unreliable"; + reason: "no-exact-match" | "no-valid-price" | "insufficient-support" | "conflict"; + support: number; + total: number; + }; + +export interface ModelCatalogRecommendation { + exactMatches: number; + metadataMethod: ModelCatalogMatchMethod; + matchedProviderId?: string; + matchedProviderName?: string; + preset: ModelCatalogPreset; + price: ModelCatalogPriceRecommendation; +} + +const CONSENSUS_MIN_SHARE = 0.6; +const KNOWN_PROVIDER_HOSTS: Record = { + anthropic: ["api.anthropic.com"], + google: ["generativelanguage.googleapis.com"], + openai: ["api.openai.com"], + openrouter: ["openrouter.ai"], +}; +const SUPPORTED_INPUT_MODALITIES = new Set(["text", "image"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function optionalNonNegativeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function optionalPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function readCost(value: unknown): ModelCatalogCost { + if (!isRecord(value)) return {}; + return { + input: optionalNonNegativeNumber(value.input), + output: optionalNonNegativeNumber(value.output), + cacheRead: optionalNonNegativeNumber(value.cache_read), + cacheWrite: optionalNonNegativeNumber(value.cache_write), + }; +} + +function readInputModalities(value: unknown): string[] | undefined { + if (!isRecord(value) || !Array.isArray(value.input)) return undefined; + const input = Array.from(new Set(value.input + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim().toLocaleLowerCase()) + .filter((entry) => SUPPORTED_INPUT_MODALITIES.has(entry)))); + return input.length ? input : undefined; +} + +function normalizeProvider(value: string): string { + return value.trim().toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function normalizeModelId(value: string): string { + return value.trim().toLocaleLowerCase().replace(/^models\//, ""); +} + +function hostname(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + return new URL(value).hostname.toLocaleLowerCase().replace(/\.$/, ""); + } catch { + return undefined; + } +} + +function hostMatches(actual: string, expected: string): boolean { + return actual === expected || actual.endsWith(`.${expected}`); +} + +function providerMatches(entry: ModelCatalogEntry, providerHint: string): boolean { + const normalizedHint = normalizeProvider(providerHint); + if (!normalizedHint) return false; + return normalizeProvider(entry.providerId) === normalizedHint + || normalizeProvider(entry.providerName) === normalizedHint; +} + +function baseUrlMatches(entry: ModelCatalogEntry, baseUrl: string): boolean { + const actualHost = hostname(baseUrl); + if (!actualHost) return false; + const knownHosts = KNOWN_PROVIDER_HOSTS[normalizeProvider(entry.providerId)] ?? []; + const providerHost = hostname(entry.providerBaseUrl); + return [...knownHosts, ...(providerHost ? [providerHost] : [])] + .some((candidate) => hostMatches(actualHost, candidate)); +} + +function exactModelMatches(entry: ModelCatalogEntry, query: string): boolean { + const normalizedQuery = normalizeModelId(query); + if (!normalizedQuery) return false; + const normalizedId = normalizeModelId(entry.id); + const normalizedFullId = `${entry.providerId.toLocaleLowerCase()}/${normalizedId}`; + return normalizedId === normalizedQuery || normalizedFullId === normalizedQuery; +} + +function validPrice(entry: ModelCatalogEntry): entry is ModelCatalogEntry & { + cost: ModelCatalogCost & { input: number; output: number }; +} { + return entry.cost.input !== undefined && entry.cost.output !== undefined; +} + +function modeValue( + values: readonly T[], + total: number, + keyFor: (value: T) => string, +): T | undefined { + if (values.length === 0 || total <= 0) return undefined; + const groups = new Map(); + for (const value of values) { + const key = keyFor(value); + const current = groups.get(key); + if (current) current.count += 1; + else groups.set(key, { value, count: 1 }); + } + const ranked = [...groups.values()].sort((a, b) => b.count - a.count); + const winner = ranked[0]; + if (!winner || winner.count / total < CONSENSUS_MIN_SHARE) return undefined; + if (ranked[1]?.count === winner.count) return undefined; + return winner.value; +} + +function modeNumber(values: readonly number[]): number | undefined { + if (values.length === 0) return undefined; + const groups = new Map(); + for (const value of values) groups.set(value, (groups.get(value) ?? 0) + 1); + const ranked = [...groups.entries()].sort((a, b) => b[1] - a[1]); + if (!ranked[0] || ranked[1]?.[1] === ranked[0][1]) return undefined; + return ranked[0][0]; +} + +function metadataFromEntry(entry: ModelCatalogEntry): ModelCatalogPreset { + return { + name: entry.name, + reasoning: entry.reasoning, + input: entry.input, + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + }; +} + +function consensusMetadata(entries: readonly ModelCatalogEntry[]): ModelCatalogPreset { + const total = entries.length; + return { + name: modeValue(entries.map((entry) => entry.name), total, (value) => value.toLocaleLowerCase()), + reasoning: modeValue( + entries.flatMap((entry) => entry.reasoning === undefined ? [] : [entry.reasoning]), + total, + String, + ), + input: modeValue( + entries.flatMap((entry) => entry.input ? [entry.input] : []), + total, + (value) => [...value].sort().join(","), + ), + contextWindow: modeValue( + entries.flatMap((entry) => entry.contextWindow === undefined ? [] : [entry.contextWindow]), + total, + String, + ), + maxTokens: modeValue( + entries.flatMap((entry) => entry.maxTokens === undefined ? [] : [entry.maxTokens]), + total, + String, + ), + }; +} + +function priceFromEntry( + entry: ModelCatalogEntry & { cost: ModelCatalogCost & { input: number; output: number } }, + method: "provider" | "base-url", +): ModelCatalogPriceRecommendation { + return { + status: "reliable", + method, + cost: entry.cost, + providerId: entry.providerId, + providerName: entry.providerName, + support: 1, + total: 1, + }; +} + +function consensusPrice(entries: readonly ModelCatalogEntry[]): ModelCatalogPriceRecommendation { + const priced = entries.filter(validPrice); + if (priced.length === 0) { + return { status: "unreliable", reason: "no-valid-price", support: 0, total: 0 }; + } + if (priced.length === 1) { + return { status: "unreliable", reason: "insufficient-support", support: 1, total: 1 }; + } + + const groups = new Map(); + for (const entry of priced) { + const key = JSON.stringify([entry.cost.input, entry.cost.output]); + const group = groups.get(key); + if (group) group.push(entry); + else groups.set(key, [entry]); + } + const ranked = [...groups.values()].sort((a, b) => b.length - a.length); + const winner = ranked[0]; + if (!winner) { + return { status: "unreliable", reason: "no-valid-price", support: 0, total: priced.length }; + } + if (ranked[1]?.length === winner.length || winner.length / priced.length < CONSENSUS_MIN_SHARE) { + return { + status: "unreliable", + reason: "conflict", + support: winner.length, + total: priced.length, + }; + } + + const cacheRead = modeNumber(winner.flatMap((entry) => entry.cost.cacheRead === undefined ? [] : [entry.cost.cacheRead])); + const cacheWrite = modeNumber(winner.flatMap((entry) => entry.cost.cacheWrite === undefined ? [] : [entry.cost.cacheWrite])); + return { + status: "reliable", + method: "consensus", + cost: { + input: winner[0].cost.input, + output: winner[0].cost.output, + cacheRead, + cacheWrite, + }, + support: winner.length, + total: priced.length, + }; +} + +export function flattenModelsDevCatalog(value: unknown): ModelCatalogEntry[] { + if (!isRecord(value)) return []; + + const entries: ModelCatalogEntry[] = []; + for (const [providerId, rawProvider] of Object.entries(value)) { + if (!isRecord(rawProvider) || !isRecord(rawProvider.models)) continue; + const providerName = cleanString(rawProvider.name) ?? providerId; + const providerBaseUrl = cleanString(rawProvider.api); + + for (const [fallbackId, rawModel] of Object.entries(rawProvider.models)) { + if (!isRecord(rawModel)) continue; + const id = cleanString(rawModel.id) ?? fallbackId; + if (!id) continue; + const name = cleanString(rawModel.name) ?? id; + const entry: ModelCatalogEntry = { + key: `${providerId}/${id}`, + providerId, + providerName, + id, + name, + cost: readCost(rawModel.cost), + }; + if (providerBaseUrl) entry.providerBaseUrl = providerBaseUrl; + if (typeof rawModel.reasoning === "boolean") entry.reasoning = rawModel.reasoning; + const input = readInputModalities(rawModel.modalities); + if (input) entry.input = input; + if (isRecord(rawModel.limit)) { + const contextWindow = optionalPositiveNumber(rawModel.limit.context); + const maxTokens = optionalPositiveNumber(rawModel.limit.output); + if (contextWindow !== undefined) entry.contextWindow = contextWindow; + if (maxTokens !== undefined) entry.maxTokens = maxTokens; + } + entries.push(entry); + } + } + + return entries; +} + +export function recommendModelCatalogPreset( + entries: readonly ModelCatalogEntry[], + query: string, + providerHint = "", + baseUrl = "", +): ModelCatalogRecommendation { + const exactEntries = entries.filter((entry) => exactModelMatches(entry, query)); + if (exactEntries.length === 0) { + return { + exactMatches: 0, + metadataMethod: "none", + preset: {}, + price: { status: "unreliable", reason: "no-exact-match", support: 0, total: 0 }, + }; + } + + const providerEntries = exactEntries.filter((entry) => providerMatches(entry, providerHint)); + const baseUrlEntries = exactEntries.filter((entry) => baseUrlMatches(entry, baseUrl)); + const metadataEntry = providerEntries[0] ?? baseUrlEntries[0]; + const metadataMethod: ModelCatalogMatchMethod = providerEntries.length + ? "provider" + : baseUrlEntries.length + ? "base-url" + : "consensus"; + const preset = metadataEntry ? metadataFromEntry(metadataEntry) : consensusMetadata(exactEntries); + + const providerPrice = providerEntries.find(validPrice); + const baseUrlPrice = baseUrlEntries.find(validPrice); + const price = providerPrice + ? priceFromEntry(providerPrice, "provider") + : baseUrlPrice + ? priceFromEntry(baseUrlPrice, "base-url") + : consensusPrice(exactEntries); + if (price.status === "reliable") preset.cost = price.cost; + + return { + exactMatches: exactEntries.length, + metadataMethod, + matchedProviderId: metadataEntry?.providerId, + matchedProviderName: metadataEntry?.providerName, + preset, + price, + }; +} + +function matchRank(entry: ModelCatalogEntry, query: string, providerHint: string): number { + const id = entry.id.toLocaleLowerCase(); + const name = entry.name.toLocaleLowerCase(); + const providerId = entry.providerId.toLocaleLowerCase(); + const providerName = entry.providerName.toLocaleLowerCase(); + const fullId = `${providerId}/${id}`; + + let rank = 20; + if (!query) rank = 10; + else if (id === query || fullId === query) rank = 0; + else if (name === query) rank = 1; + else if (id.startsWith(query) || name.startsWith(query)) rank = 2; + else if (fullId.startsWith(query) || providerId === query || providerName === query) rank = 3; + else if (id.includes(query) || name.includes(query)) rank = 4; + else if (fullId.includes(query) || providerName.includes(query)) rank = 5; + + if (rank < 20 && providerHint && (providerId === providerHint || providerName === providerHint)) rank -= 0.5; + return rank; +} + +export function searchModelCatalog( + entries: readonly ModelCatalogEntry[], + query: string, + providerHint = "", + limit = 50, +): ModelCatalogEntry[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + const normalizedProvider = providerHint.trim().toLocaleLowerCase(); + const cappedLimit = Math.max(1, Math.min(100, Math.floor(limit) || 50)); + + return entries + .map((entry) => ({ entry, rank: matchRank(entry, normalizedQuery, normalizedProvider) })) + .filter(({ rank }) => !normalizedQuery || rank < 20) + .sort((a, b) => a.rank - b.rank + || a.entry.providerName.localeCompare(b.entry.providerName, undefined, { sensitivity: "base" }) + || a.entry.name.localeCompare(b.entry.name, undefined, { numeric: true, sensitivity: "base" }) + || a.entry.id.localeCompare(b.entry.id, undefined, { numeric: true, sensitivity: "base" })) + .slice(0, cappedLimit) + .map(({ entry }) => entry); +} diff --git a/pi-web/lib/model-discovery-auth.ts b/server/src/lib/model-discovery-auth.ts similarity index 100% rename from pi-web/lib/model-discovery-auth.ts rename to server/src/lib/model-discovery-auth.ts diff --git a/server/src/lib/model-discovery.ts b/server/src/lib/model-discovery.ts new file mode 100644 index 0000000..15eebf3 --- /dev/null +++ b/server/src/lib/model-discovery.ts @@ -0,0 +1,75 @@ +export interface DiscoveredModel { + id: string; + name?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function modelFromValue(value: unknown): DiscoveredModel | null { + if (typeof value === "string") { + const id = value.trim(); + return id ? { id } : null; + } + if (!isRecord(value)) return null; + + const rawId = cleanString(value.id) ?? cleanString(value.model) ?? cleanString(value.name); + if (!rawId) return null; + const id = rawId.startsWith("models/") ? rawId.slice("models/".length) : rawId; + if (!id) return null; + const name = cleanString(value.display_name) + ?? cleanString(value.displayName) + ?? (cleanString(value.id) || cleanString(value.model) ? cleanString(value.name) : undefined); + return name && name !== id ? { id, name } : { id }; +} + +function listFromResponse(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (!isRecord(value)) return []; + for (const key of ["data", "models", "results", "items"]) { + const candidate = value[key]; + if (Array.isArray(candidate)) return candidate; + if (isRecord(candidate)) return Object.values(candidate); + } + return []; +} + +export function parseDiscoveredModels(value: unknown): DiscoveredModel[] { + const seen = new Set(); + const models: DiscoveredModel[] = []; + for (const item of listFromResponse(value)) { + const model = modelFromValue(item); + if (!model || seen.has(model.id)) continue; + seen.add(model.id); + models.push(model); + } + return models.sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id, undefined, { + numeric: true, + sensitivity: "base", + })); +} + +export function buildModelsListUrl(baseUrl: string, api: string): URL { + const url = new URL(baseUrl.trim()); + const trimmedPath = url.pathname.replace(/\/+$/, ""); + + if (!/\/models$/i.test(trimmedPath)) { + let path = trimmedPath; + if (api === "anthropic-messages" && !/\/v\d+(?:beta)?$/i.test(path)) path += "/v1"; + if (api === "google-generative-ai" && !/\/v\d+(?:beta)?$/i.test(path)) path += "/v1beta"; + url.pathname = `${path}/models`.replace(/\/+/g, "/"); + } + + if (api === "anthropic-messages" && !url.searchParams.has("limit")) { + url.searchParams.set("limit", "1000"); + } + if (api === "google-generative-ai" && !url.searchParams.has("pageSize")) { + url.searchParams.set("pageSize", "1000"); + } + return url; +} diff --git a/pi-web/lib/models-cache.test.mjs b/server/src/lib/models-cache.test.mjs similarity index 100% rename from pi-web/lib/models-cache.test.mjs rename to server/src/lib/models-cache.test.mjs diff --git a/pi-web/lib/models-cache.ts b/server/src/lib/models-cache.ts similarity index 100% rename from pi-web/lib/models-cache.ts rename to server/src/lib/models-cache.ts diff --git a/server/src/lib/normalize.ts b/server/src/lib/normalize.ts new file mode 100644 index 0000000..28c518b --- /dev/null +++ b/server/src/lib/normalize.ts @@ -0,0 +1,32 @@ +import type { AgentMessage, AssistantMessage, ToolCallContent } from "./types"; + +function isObject(val: unknown): val is Record { + return typeof val === "object" && val !== null && !Array.isArray(val); +} + +function normalizeToolCallBlock(block: unknown): ToolCallContent | null { + if (!isObject(block) || block.type !== "toolCall") return null; + return { + type: "toolCall", + toolCallId: typeof block.toolCallId === "string" ? block.toolCallId : (typeof block.id === "string" ? block.id : ""), + toolName: typeof block.toolName === "string" ? block.toolName : (typeof block.name === "string" ? block.name : ""), + input: typeof block.input === "object" && block.input !== null && !Array.isArray(block.input) + ? block.input as Record + : (typeof block.arguments === "object" && block.arguments !== null && !Array.isArray(block.arguments) + ? block.arguments as Record + : {}), + }; +} + +export function normalizeToolCalls(msg: AgentMessage): AgentMessage { + // Non-assistant roles (user, toolResult, bashExecution, custom) are returned + // unchanged — only assistant messages go through tool-call field normalization. + if (msg.role !== "assistant") return msg; + const content = (msg as AssistantMessage).content; + if (!Array.isArray(content)) return msg; + const normalized = content.map((block) => { + const result = normalizeToolCallBlock(block); + return result ?? block; + }); + return { ...msg, content: normalized } as AgentMessage; +} \ No newline at end of file diff --git a/pi-web/lib/npx.ts b/server/src/lib/npx.ts similarity index 100% rename from pi-web/lib/npx.ts rename to server/src/lib/npx.ts diff --git a/pi-web/lib/path-security.ts b/server/src/lib/path-security.ts similarity index 100% rename from pi-web/lib/path-security.ts rename to server/src/lib/path-security.ts diff --git a/server/src/lib/pi-types.ts b/server/src/lib/pi-types.ts new file mode 100644 index 0000000..c2b68e2 --- /dev/null +++ b/server/src/lib/pi-types.ts @@ -0,0 +1,167 @@ +import type { + AgentSessionEvent, + SessionManager, + SettingsManager, + SlashCommandInfo, + Theme, +} from "@earendil-works/pi-coding-agent"; + +export interface ContextUsage { + percent: number | null; + contextWindow: number; + tokens: number | null; +} + +export interface ModelLike { + id: string; + provider: string; +} + +export interface ToolInfo { + name: string; + description: string; +} + +export interface NavigateTreeResult { + editorText?: string; + cancelled: boolean; + aborted?: boolean; +} + +export interface SessionStatsInfo { + sessionFile?: string; + sessionId: string; + sessionName?: string; + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; + contextUsage?: ContextUsage; +} + +interface PromptTemplateLike { + name: string; + description?: string; + sourceInfo: SlashCommandInfo["sourceInfo"]; +} + +interface SkillLike { + name: string; + description?: string; + sourceInfo: SlashCommandInfo["sourceInfo"]; +} + +interface ResourceLoaderLike { + getSkills(): { skills: SkillLike[] }; +} + +interface ExtensionRunnerLike { + getRegisteredCommands(): Array<{ + invocationName: string; + description?: string; + sourceInfo: SlashCommandInfo["sourceInfo"]; + }>; + setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void; +} + +type DialogOptionsLike = { + signal?: AbortSignal; + timeout?: number; +}; + +type WidgetOptionsLike = { + placement?: "aboveEditor" | "belowEditor"; +}; + +export interface ExtensionUiContextLike { + select(title: string, options: string[], opts?: DialogOptionsLike): Promise; + confirm(title: string, message: string, opts?: DialogOptionsLike): Promise; + input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise; + editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise; + notify(message: string, type?: "info" | "warning" | "error"): void; + onTerminalInput(): () => void; + setStatus(key: string, text: string | undefined): void; + setWorkingMessage(message?: string): void; + setWorkingVisible(visible: boolean): void; + setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void; + setHiddenThinkingLabel(label?: string): void; + setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void; + setFooter(factory: unknown): void; + setHeader(factory: unknown): void; + setTitle(title: string): void; + custom(...args: unknown[]): Promise; + pasteToEditor(text: string): void; + setEditorText(text: string): void; + getEditorText(): string; + addAutocompleteProvider(): void; + setEditorComponent(): void; + getEditorComponent(): undefined; + readonly theme: Theme; + getAllThemes(): unknown[]; + getTheme(name: string): undefined; + setTheme(theme: unknown): { success: boolean; error?: string }; + getToolsExpanded(): boolean; + setToolsExpanded(expanded: boolean): void; +} + +export interface AgentSessionLike { + readonly sessionId: string; + readonly sessionFile: string | undefined; + readonly isStreaming: boolean; + readonly isCompacting: boolean; + readonly autoCompactionEnabled: boolean; + readonly autoRetryEnabled: boolean; + readonly model: ModelLike | undefined; + readonly modelRuntime: { + getModel: (provider: string, modelId: string) => ModelLike | undefined; + refresh: (options?: { allowNetwork?: boolean }) => Promise; + }; + readonly sessionManager: SessionManager; + readonly settingsManager: SettingsManager; + readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } }; + readonly extensionRunner: ExtensionRunnerLike; + readonly promptTemplates: readonly PromptTemplateLike[]; + readonly resourceLoader: ResourceLoaderLike; + + readonly bindExtensions?: unknown; + reload(options?: { beforeSessionStart?: () => void | Promise }): Promise; + subscribe(listener: (event: AgentSessionEvent) => void): () => void; + prompt(text: string, options?: { + images?: Array<{ type: "image"; data: string; mimeType: string }>; + streamingBehavior?: "steer" | "followUp"; + source?: "interactive" | "rpc"; + }): Promise; + abort(): Promise; + executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>; + abortBash(): void; + readonly isBashRunning: boolean; + setModel(model: ModelLike): Promise; + navigateTree(targetId: string, options?: { summarize?: boolean }): Promise; + setThinkingLevel(level: string): void; + compact(customInstructions?: string): Promise; + setSessionName(name: string): void; + getSessionStats(): Omit; + getLastAssistantText(): string | undefined; + setAutoCompactionEnabled(enabled: boolean): void; + setAutoRetryEnabled(enabled: boolean): void; + steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; + followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; + readonly pendingMessageCount: number; + getSteeringMessages(): readonly string[]; + getFollowUpMessages(): readonly string[]; + clearQueue(): { steering: string[]; followUp: string[] }; + getAllTools(): ToolInfo[]; + getActiveToolNames(): string[]; + setActiveToolsByName(names: string[]): void; + abortCompaction(): void; + getContextUsage(): ContextUsage | undefined; +} diff --git a/pi-web/lib/project-trust.test.mjs b/server/src/lib/project-trust.test.mjs similarity index 92% rename from pi-web/lib/project-trust.test.mjs rename to server/src/lib/project-trust.test.mjs index db8571f..ec387d4 100644 --- a/pi-web/lib/project-trust.test.mjs +++ b/server/src/lib/project-trust.test.mjs @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -103,10 +103,10 @@ test("the reload resolver reads the latest persisted trust decision", async (t) test("all project resource loaders and reloads enforce project trust", async () => { const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8"); - const modelsSource = await readFile(new URL("../app/api/models/route.ts", import.meta.url), "utf8"); + const modelsSource = await readFile(new URL("../routes/models.ts", import.meta.url), "utf8"); const skillsSource = await readFile(new URL("./skills-service.ts", import.meta.url), "utf8"); - const skillsInstallSource = await readFile(new URL("../app/api/skills/install/route.ts", import.meta.url), "utf8"); - const pluginsSource = await readFile(new URL("../app/api/plugins/route.ts", import.meta.url), "utf8"); + const skillsInstallSource = await readFile(new URL("../routes/skills.ts", import.meta.url), "utf8"); + const pluginsSource = await readFile(new URL("../routes/config.ts", import.meta.url), "utf8"); assert.match(rpcSource, /projectTrustReloadOptions\(cwd, agentDir\)/); assert.match(rpcSource, /resourceLoaderReloadOptions: trustReloadOptions/); @@ -131,7 +131,7 @@ test("all project resource loaders and reloads enforce project trust", async () }); test("the trust API invalidates cached models and restricted runtimes", async () => { - const source = await readFile(new URL("../app/api/project-trust/route.ts", import.meta.url), "utf8"); + const source = await readFile(new URL("../routes/misc.ts", import.meta.url), "utf8"); const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8"); assert.match(source, /trustProject\(result\.cwd, agentDir\)/); diff --git a/pi-web/lib/project-trust.ts b/server/src/lib/project-trust.ts similarity index 100% rename from pi-web/lib/project-trust.ts rename to server/src/lib/project-trust.ts diff --git a/pi-web/lib/request-security.test.mjs b/server/src/lib/request-security.test.mjs similarity index 78% rename from pi-web/lib/request-security.test.mjs rename to server/src/lib/request-security.test.mjs index 0e42c2c..6f3f11b 100644 --- a/pi-web/lib/request-security.test.mjs +++ b/server/src/lib/request-security.test.mjs @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; import test from "node:test"; async function loadSubject() { @@ -166,3 +166,49 @@ test("recognizes JSON request content types", async () => { headers: { "content-type": "text/plain" }, })), false); }); + +test("allows frontend origins proxied to the backend host", async () => { + // The standalone backend receives /api through the frontend's rewrites, so + // the Host header points at the backend while Origin stays the frontend's. + const { isApiRequestAllowed } = await loadSubject(); + const proxiedPost = new Request("http://127.0.0.1:30142/api/default-cwd", { + method: "POST", + headers: { + host: "127.0.0.1:30142", + origin: "http://127.0.0.1:30141", + "sec-fetch-site": "same-origin", + }, + }); + const proxiedLanPost = new Request("http://127.0.0.1:30142/api/default-cwd", { + method: "POST", + headers: { + host: "127.0.0.1:30142", + origin: "http://192.168.32.7:30141", + "sec-fetch-site": "same-origin", + }, + }); + assert.equal(isApiRequestAllowed(proxiedPost), true); + assert.equal(isApiRequestAllowed(proxiedLanPost), true); +}); + +test("still rejects cross-site and wrong-port origins through the proxy", async () => { + const { isApiRequestAllowed } = await loadSubject(); + const crossSite = new Request("http://127.0.0.1:30142/api/default-cwd", { + method: "POST", + headers: { + host: "127.0.0.1:30142", + origin: "https://attacker.example", + "sec-fetch-site": "cross-site", + }, + }); + const wrongPort = new Request("http://127.0.0.1:30142/api/default-cwd", { + method: "POST", + headers: { + host: "127.0.0.1:30142", + origin: "http://127.0.0.1:8080", + "sec-fetch-site": "same-origin", + }, + }); + assert.equal(isApiRequestAllowed(crossSite), false); + assert.equal(isApiRequestAllowed(wrongPort), false); +}); diff --git a/pi-web/lib/request-security.ts b/server/src/lib/request-security.ts similarity index 80% rename from pi-web/lib/request-security.ts rename to server/src/lib/request-security.ts index 5d6d35d..ca7c465 100644 --- a/pi-web/lib/request-security.ts +++ b/server/src/lib/request-security.ts @@ -51,6 +51,29 @@ function getRequestOrigin(request: Request): string | null { return host ? canonicalOrigin(`${requestUrl.protocol}//${host}`) : null; } +/** + * The frontend proxies /api here server-side, which rewrites the Host header + * to the backend address. The "origin equals request host" comparison below + * would then reject every browser POST. Accept origins that belong to the + * frontend itself: loopback names, IP literals, or operator-configured + * hostnames on the frontend port. Cross-site origins (the DNS-rebinding + * defense this check exists for) still fail. + */ +function isFrontendOrigin(origin: string): boolean { + try { + const url = new URL(origin); + const expectedPort = process.env.PI_WEB_PORT ?? "30141"; + if ((url.port || "80") !== expectedPort) return false; + const hostname = normalizeHostname(url.hostname); + if (isLoopbackHostname(hostname) || isIP(hostname) !== 0) return true; + return configuredHostnamesFromEnvironment().some( + (configured) => normalizeConfiguredHostname(configured) === hostname, + ); + } catch { + return false; + } +} + function isUserInitiatedSessionExportNavigation(request: Request): boolean { if ( request.method !== "GET" @@ -93,6 +116,7 @@ export function isApiRequestOriginAllowed(request: Request): boolean { const fetchSite = request.headers.get("sec-fetch-site"); if (fetchSite === "cross-site") return false; if (!origin) return true; + if (isFrontendOrigin(origin)) return true; const requestOrigin = getRequestOrigin(request); return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin; diff --git a/pi-web/lib/rpc-manager.test.mjs b/server/src/lib/rpc-manager.test.mjs similarity index 100% rename from pi-web/lib/rpc-manager.test.mjs rename to server/src/lib/rpc-manager.test.mjs diff --git a/pi-web/lib/rpc-manager.ts b/server/src/lib/rpc-manager.ts similarity index 100% rename from pi-web/lib/rpc-manager.ts rename to server/src/lib/rpc-manager.ts diff --git a/pi-web/lib/session-file-references-core.ts b/server/src/lib/session-file-references-core.ts similarity index 100% rename from pi-web/lib/session-file-references-core.ts rename to server/src/lib/session-file-references-core.ts diff --git a/pi-web/lib/session-file-references.test.mjs b/server/src/lib/session-file-references.test.mjs similarity index 100% rename from pi-web/lib/session-file-references.test.mjs rename to server/src/lib/session-file-references.test.mjs diff --git a/pi-web/lib/session-file-references.ts b/server/src/lib/session-file-references.ts similarity index 100% rename from pi-web/lib/session-file-references.ts rename to server/src/lib/session-file-references.ts diff --git a/pi-web/lib/session-path.test.mjs b/server/src/lib/session-path.test.mjs similarity index 100% rename from pi-web/lib/session-path.test.mjs rename to server/src/lib/session-path.test.mjs diff --git a/pi-web/lib/session-path.ts b/server/src/lib/session-path.ts similarity index 100% rename from pi-web/lib/session-path.ts rename to server/src/lib/session-path.ts diff --git a/pi-web/lib/session-reader.test.mjs b/server/src/lib/session-reader.test.mjs similarity index 100% rename from pi-web/lib/session-reader.test.mjs rename to server/src/lib/session-reader.test.mjs diff --git a/pi-web/lib/session-reader.ts b/server/src/lib/session-reader.ts similarity index 100% rename from pi-web/lib/session-reader.ts rename to server/src/lib/session-reader.ts diff --git a/pi-web/lib/session-title.test.mjs b/server/src/lib/session-title.test.mjs similarity index 100% rename from pi-web/lib/session-title.test.mjs rename to server/src/lib/session-title.test.mjs diff --git a/pi-web/lib/session-title.ts b/server/src/lib/session-title.ts similarity index 100% rename from pi-web/lib/session-title.ts rename to server/src/lib/session-title.ts diff --git a/pi-web/lib/skill-lock.test.mjs b/server/src/lib/skill-lock.test.mjs similarity index 99% rename from pi-web/lib/skill-lock.test.mjs rename to server/src/lib/skill-lock.test.mjs index 13d82fa..de1c4f4 100644 --- a/pi-web/lib/skill-lock.test.mjs +++ b/server/src/lib/skill-lock.test.mjs @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/pi-web/lib/skill-lock.ts b/server/src/lib/skill-lock.ts similarity index 99% rename from pi-web/lib/skill-lock.ts rename to server/src/lib/skill-lock.ts index 190d0f3..7b79d10 100644 --- a/pi-web/lib/skill-lock.ts +++ b/server/src/lib/skill-lock.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { isAbsolute, join, relative, resolve, sep } from "path"; -import type { SkillInfo, SkillInstallInfo, SkillInstallScope } from "@/lib/api-types"; +import type { SkillInfo, SkillInstallInfo, SkillInstallScope } from "./api-types"; interface SkillLockEntry { source?: unknown; diff --git a/pi-web/lib/skill-updates.test.mjs b/server/src/lib/skill-updates.test.mjs similarity index 99% rename from pi-web/lib/skill-updates.test.mjs rename to server/src/lib/skill-updates.test.mjs index bd289fd..b7a5f5e 100644 --- a/pi-web/lib/skill-updates.test.mjs +++ b/server/src/lib/skill-updates.test.mjs @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import assert from "node:assert/strict"; import test from "node:test"; import { createJiti } from "jiti"; diff --git a/pi-web/lib/skill-updates.ts b/server/src/lib/skill-updates.ts similarity index 99% rename from pi-web/lib/skill-updates.ts rename to server/src/lib/skill-updates.ts index a390899..bb514b5 100644 --- a/pi-web/lib/skill-updates.ts +++ b/server/src/lib/skill-updates.ts @@ -6,7 +6,7 @@ import { promisify } from "util"; import type { SkillInstallInfo, SkillUpdateResult, -} from "@/lib/api-types"; +} from "./api-types"; const CHECK_TIMEOUT_MS = 15_000; const GIT_CHECK_TIMEOUT_MS = 30_000; diff --git a/pi-web/lib/skills-service.ts b/server/src/lib/skills-service.ts similarity index 85% rename from pi-web/lib/skills-service.ts rename to server/src/lib/skills-service.ts index f248dbd..ae9187a 100644 --- a/pi-web/lib/skills-service.ts +++ b/server/src/lib/skills-service.ts @@ -1,13 +1,13 @@ import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent"; -import type { SkillInfo, SkillsResponse } from "@/lib/api-types"; -import { annotateSkillsWithInstallInfo } from "@/lib/skill-lock"; -import { getProjectTrustStatus, projectTrustReloadOptions } from "@/lib/project-trust"; +import type { SkillInfo, SkillsResponse } from "./api-types"; +import { annotateSkillsWithInstallInfo } from "./skill-lock"; +import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; import { createAppSettingsManager, getAppResourceLoaderOptions, getManagedRuntimePaths, isManagedRuntime, -} from "@/lib/app-runtime"; +} from "./app-runtime"; export async function loadSkillsWithInstallInfo(cwd: string): Promise { const agentDir = getAgentDir(); diff --git a/server/src/lib/types.ts b/server/src/lib/types.ts new file mode 100644 index 0000000..65d8a80 --- /dev/null +++ b/server/src/lib/types.ts @@ -0,0 +1,308 @@ +// Types mirrored from pi-mono coding-agent session-manager + +export interface SessionHeader { + type: "session"; + version?: number; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +export interface SessionEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface TextContent { + type: "text"; + text: string; +} + +export interface ImageContent { + type: "image"; + source: { + type: "base64" | "url"; + media_type?: string; + data?: string; + url?: string; + }; +} + +export interface ThinkingContent { + type: "thinking"; + thinking: string; + /** Historical content omitted from the initial response and loaded on demand. */ + deferred?: boolean; +} + +export interface ToolCallContent { + type: "toolCall"; + toolCallId: string; + toolName: string; + input: Record; +} + +export type AssistantContentBlock = TextContent | ImageContent | ThinkingContent | ToolCallContent; + +export interface UserMessage { + role: "user"; + content: string | (TextContent | ImageContent)[]; + timestamp?: number; +} + +export interface AssistantMessage { + role: "assistant"; + content: AssistantContentBlock[]; + model: string; + provider: string; + stopReason?: string; + errorMessage?: string; + timestamp?: number; + usage?: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + }; +} + +export interface ToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName?: string; + content: (TextContent | ImageContent)[]; + isError?: boolean; + details?: unknown; + timestamp?: number; +} + +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: unknown; + timestamp?: number; +} + +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode?: number; + cancelled?: boolean; + truncated?: boolean; + fullOutputPath?: string; + excludeFromContext?: boolean; + timestamp?: number; +} + +export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage | CustomMessage | BashExecutionMessage; + +export type ExtensionUiRequest = + | { + type: "extension_ui_request"; + id: string; + method: "select"; + title: string; + options: string[]; + timeout?: number; + expiresAt?: number; + } + | { + type: "extension_ui_request"; + id: string; + method: "confirm"; + title: string; + message: string; + timeout?: number; + expiresAt?: number; + } + | { + type: "extension_ui_request"; + id: string; + method: "input"; + title: string; + placeholder?: string; + timeout?: number; + expiresAt?: number; + } + | { + type: "extension_ui_request"; + id: string; + method: "editor"; + title: string; + prefill?: string; + timeout?: number; + expiresAt?: number; + } + | { + type: "extension_ui_request"; + id: string; + method: "notify"; + message: string; + notifyType?: "info" | "warning" | "error"; + } + | { + type: "extension_ui_request"; + id: string; + method: "setStatus"; + statusKey: string; + statusText?: string; + } + | { + type: "extension_ui_request"; + id: string; + method: "setWidget"; + widgetKey: string; + widgetLines?: string[]; + widgetPlacement?: "aboveEditor" | "belowEditor"; + } + | { + type: "extension_ui_request"; + id: string; + method: "setTitle"; + title: string; + } + | { + type: "extension_ui_request"; + id: string; + method: "set_editor_text"; + text: string; + } + | { + type: "extension_ui_request"; + id: string; + method: "custom"; + lines: string[]; + closed?: boolean; + }; + +export type ExtensionUiResponse = + | { type: "extension_ui_response"; id: string; value: string } + | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; cancelled: true }; + +export interface ExtensionStatusItem { + key: string; + text: string; +} + +export interface ExtensionWidgetItem { + key: string; + lines: string[]; + placement: "aboveEditor" | "belowEditor"; +} + +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: unknown; + fromHook?: boolean; +} + +export interface CustomEntry extends SessionEntryBase { + type: "custom"; + customType: string; + data?: unknown; +} + +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: unknown; + display: boolean; +} + +export interface LabelEntry extends SessionEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface SessionInfoEntry extends SessionEntryBase { + type: "session_info"; + name?: string; +} + +export type SessionEntry = + | SessionMessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry; + +export type FileEntry = SessionHeader | SessionEntry; + +export interface SessionTreeNode { + entry: SessionEntry; + children: SessionTreeNode[]; + label?: string; + compressedEntryIds?: string[]; +} + +export interface SessionInfo { + path: string; + id: string; + cwd: string; + name?: string; + created: string; + modified: string; + messageCount: number; + firstMessage: string; + parentSessionId?: string; // set if this session was forked from another + /** Main repo root shared by all worktrees of this cwd (cwd itself for non-git dirs). + * Always set by the server; optional because the client builds transient + * SessionInfo objects before the first refresh. Fall back to cwd. */ + projectRoot?: string; + /** Branch name when cwd is a linked git worktree (not the main checkout) */ + worktreeBranch?: string; +} + +export interface SessionContext { + messages: AgentMessage[]; + entryIds: string[]; // parallel to messages — the session entry id for each message + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} diff --git a/pi-web/lib/worktree.ts b/server/src/lib/worktree.ts similarity index 100% rename from pi-web/lib/worktree.ts rename to server/src/lib/worktree.ts diff --git a/server/src/routes/agent.ts b/server/src/routes/agent.ts new file mode 100644 index 0000000..1b97af3 --- /dev/null +++ b/server/src/routes/agent.ts @@ -0,0 +1,282 @@ +import type { Hono } from "hono"; +import { existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { Readable } from "node:stream"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { allowFileRoot } from "../lib/allowed-roots"; +import { + MAX_INLINE_BASH_OUTPUT_BYTES, + openRegularFileNoFollow, + readUtf8FileWithinLimit, + resolveBashOutputPath, +} from "../lib/bash-output"; +import { + getRpcSession, + getRunningRpcSessionIds, + startRpcSession, + subscribeRunningSessions, +} from "../lib/rpc-manager"; +import { isBashOutputPathReferencedBySession } from "../lib/session-file-references"; +import { invalidateSessionListCache, resolveSessionPath } from "../lib/session-reader"; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", +} as const; + +export function registerAgentRoutes(app: Hono): void { + // POST /api/agent/new body: { cwd: string; type: string; message?: string; ... } + // Spawns a brand-new pi session. Most calls immediately send the first command; + // type:"ensure_session" only creates the runtime so clients can query commands. + // Returns { sessionId, data } where sessionId is pi's real session id. + app.post("/api/agent/new", async (c) => { + try { + const body = (await c.req.json()) as { cwd?: string; [key: string]: unknown }; + const { cwd, ...command } = body; + + if (!cwd || typeof cwd !== "string") { + return c.json({ error: "cwd is required" }, 400); + } + if (!existsSync(cwd)) { + return c.json({ error: `Directory does not exist: ${cwd}` }, 400); + } + + // Use a one-time key so startRpcSession's lock doesn't conflict with real session ids + const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown }; + + // Must be unique per request: startRpcSession coalesces concurrent callers + // that share a key onto one session. Date.now() (ms resolution) collides for + // requests in the same millisecond, merging two new sessions into one. + const tempKey = `__new__${randomUUID()}`; + const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames); + + // Keep the files-route allowed-roots cache in sync so the new cwd is + // immediately readable via /api/files. Without this, a file request under + // a brand-new cwd would 403 for up to the cache TTL. + allowFileRoot(cwd); + invalidateSessionListCache(); + + // Apply pre-selected model before sending the prompt + if (provider && modelId) { + await session.send({ type: "set_model", provider, modelId }); + } + + // Apply pre-selected thinking level before sending the prompt + if (thinkingLevel) { + await session.send({ type: "set_thinking_level", level: thinkingLevel }); + } + + if (promptCommand.type === "ensure_session") { + return c.json({ success: true, sessionId: realSessionId, data: null }); + } + + const result = await session.send(promptCommand); + + return c.json({ success: true, sessionId: realSessionId, data: result }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // GET /api/agent/running/events - SSE stream of the set of currently-running + // session ids. Pushes an update whenever any session starts or stops working, + // so the sidebar never has to poll. + app.get("/api/agent/running/events", (c) => { + const signal = c.req.raw.signal; + const stream = new ReadableStream({ + start(controller) { + const encode = (data: unknown) => { + const text = `data: ${JSON.stringify(data)}\n\n`; + controller.enqueue(new TextEncoder().encode(text)); + }; + + // Subscribe BEFORE taking the initial snapshot so no state change can slip + // through the gap between snapshot and subscription. + const unsubscribe = subscribeRunningSessions((ids) => { + try { + encode({ type: "running", runningSessionIds: ids }); + } catch { + // controller already closed + } + }); + + // Initial snapshot so the client renders the correct state immediately. + // (A duplicate frame here is harmless: the client just sets the same set.) + encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() }); + + // Heartbeat to keep the connection alive through proxies/timeouts. + const heartbeat = setInterval(() => { + try { + controller.enqueue(new TextEncoder().encode(":\n\n")); + } catch { + // controller already closed + } + }, 30_000); + + const cleanup = () => { + clearInterval(heartbeat); + unsubscribe(); + try { controller.close(); } catch { /* already closed */ } + }; + + signal?.addEventListener("abort", cleanup); + }, + }); + + return new Response(stream, { headers: SSE_HEADERS }); + }); + + // GET /api/agent/:id/events - SSE stream of agent events + app.get("/api/agent/:id/events", async (c) => { + const id = c.req.param("id"); + + // Fast path: already-running session + let session = getRpcSession(id); + if (!session || !session.isAlive()) { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return new Response("Session not found", { status: 404 }); + } + const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); + try { + ({ session } = await startRpcSession(id, filePath, cwd)); + } catch (error) { + return new Response(`Failed to start agent: ${error}`, { status: 500 }); + } + } + + const signal = c.req.raw.signal; + const stream = new ReadableStream({ + start(controller) { + const encode = (data: unknown) => { + const text = `data: ${JSON.stringify(data)}\n\n`; + controller.enqueue(new TextEncoder().encode(text)); + }; + + // Send initial connected event + encode({ type: "connected", sessionId: id }); + + const unsubscribe = session.onEvent((event) => { + encode(event); + }); + + // Heartbeat to keep the connection alive through proxies/timeouts. + const heartbeat = setInterval(() => { + try { + controller.enqueue(new TextEncoder().encode(":\n\n")); + } catch { + // controller already closed + } + }, 30_000); + + // Cleanup when client disconnects + const cleanup = () => { + clearInterval(heartbeat); + unsubscribe(); + controller.close(); + }; + + // Detect client disconnect via abort signal + signal?.addEventListener("abort", cleanup); + }, + }); + + return new Response(stream, { headers: SSE_HEADERS }); + }); + + // GET /api/agent/:id/bash-output?path= + // Reads a bash output temp file referenced by this session. Inline display is + // size-limited; download responses stream the file without buffering it. + app.get("/api/agent/:id/bash-output", async (c) => { + const id = c.req.param("id"); + const path = c.req.query("path"); + const download = c.req.query("download") === "1"; + + if (!path) { + return c.json({ error: "path required" }, 400); + } + + const resolved = resolveBashOutputPath(path, tmpdir()); + if (!resolved) { + return c.json({ error: "invalid path" }, 400); + } + + if (!(await isBashOutputPathReferencedBySession(resolved, id))) { + return c.json({ error: "forbidden" }, 403); + } + + try { + if (download) { + const { handle } = await openRegularFileNoFollow(resolved); + const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream; + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": "attachment; filename=\"bash-output.log\"", + "Cache-Control": "no-store", + }, + }); + } + + const result = await readUtf8FileWithinLimit(resolved); + if (result.tooLarge) { + return c.json({ + error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`, + data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES }, + }, 413); + } + return c.json({ success: true, data: { output: result.content } }); + } catch { + return c.json({ error: "full output unavailable" }, 404); + } + }); + + // POST /api/agent/:id - Send a command to an existing session + app.post("/api/agent/:id", async (c) => { + const id = c.req.param("id"); + + try { + const body = (await c.req.json()) as { type: string; [key: string]: unknown }; + + // Fast path: already-running session + const existing = getRpcSession(id); + if (existing?.isAlive()) { + const result = await existing.send(body); + return c.json({ success: true, data: result }); + } + + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); + + const { session } = await startRpcSession(id, filePath, cwd); + const result = await session.send(body); + + return c.json({ success: true, data: result }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // GET /api/agent/:id - Get current agent state + app.get("/api/agent/:id", async (c) => { + const id = c.req.param("id"); + + try { + const session = getRpcSession(id); + if (!session || !session.isAlive()) { + return c.json({ running: false }); + } + + const state = await session.send({ type: "get_state" }); + return c.json({ running: true, state }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..74d9341 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,315 @@ +import type { Hono } from "hono"; +import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { invalidateModelsCache } from "../lib/models-cache"; + +// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise. +// Module-level in the backend process (globalThis in the Next.js original). +const loginCallbacks = new Map void; reject: (e: Error) => void }>(); + +export function registerAuthRoutes(app: Hono): void { + // GET /api/auth/providers — OAuth-capable providers and login state + app.get("/api/auth/providers", async (c) => { + const modelRuntime = await ModelRuntime.create(); + const credentials = await modelRuntime.listCredentials(); + const loggedInProviders = new Set( + credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId), + ); + const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth); + + const EXCLUDED = new Set(["anthropic"]); + const DISPLAY_NAMES: Record = { + "openai-codex": "ChatGPT Plus/Pro", + "github-copilot": "GitHub Copilot", + }; + + const result = await Promise.all( + providers + .filter((p) => !EXCLUDED.has(p.id)) + .map(async (p) => { + return { + id: p.id, + name: DISPLAY_NAMES[p.id] ?? p.name, + usesCallbackServer: false, + loggedIn: loggedInProviders.has(p.id), + }; + }) + ); + + return c.json({ providers: result }); + }); + + // GET /api/auth/all-providers — API-key providers (non-OAuth, non-custom) + app.get("/api/auth/all-providers", async (c) => { + // Providers that use OAuth — handled separately via /api/auth/providers + const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]); + + const modelRuntime = await ModelRuntime.create(); + const all = modelRuntime.getModels(); + + // Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key) + const seen = new Set(); + const result: { + id: string; + displayName: string; + configured: boolean; + source?: string; + modelCount: number; + }[] = []; + + for (const provider of modelRuntime.getProviders()) { + if (seen.has(provider.id)) continue; + seen.add(provider.id); + if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue; + const status = modelRuntime.getProviderAuthStatus(provider.id); + // Skip providers whose key comes from models.json (those are custom providers) + if (status.source === "models_json_key") continue; + const modelCount = all.filter((model) => model.provider === provider.id).length; + result.push({ + id: provider.id, + displayName: provider.name, + configured: status.configured, + source: status.source, + modelCount, + }); + } + + return c.json({ providers: result }); + }); + + // POST /api/auth/login/:provider — frontend sends redirect URL or auth code + app.post("/api/auth/login/:provider", async (c) => { + const provider = c.req.param("provider"); + const { token, code } = (await c.req.json()) as { token?: string; code?: string }; + + if (!token || !code) { + return c.json({ error: "token and code required" }, 400); + } + + const callbacks = loginCallbacks.get(token); + if (!callbacks) { + return c.json({ error: "No pending login for token" }, 404); + } + // Verify token belongs to this provider (token format: "--") + if (!token.startsWith(`${provider}-`)) { + return c.json({ error: "Token does not match provider" }, 400); + } + + callbacks.resolve(code); + loginCallbacks.delete(token); + return c.json({ ok: true, provider }); + }); + + // GET /api/auth/login/:provider — SSE stream for OAuth flow + app.get("/api/auth/login/:provider", (c) => { + const provider = c.req.param("provider"); + + const encoder = new TextEncoder(); + const send = (controller: ReadableStreamDefaultController, data: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + // AbortController propagates client disconnect into ModelRuntime.login(). + const abort = new AbortController(); + c.req.raw.signal.addEventListener("abort", () => abort.abort()); + + const stream = new ReadableStream({ + async start(controller) { + const modelRuntime = await ModelRuntime.create(); + if (!modelRuntime.getProvider(provider)?.auth.oauth) { + send(controller, { type: "error", message: `Unknown provider: ${provider}` }); + controller.close(); + return; + } + + const activeTokens = new Set(); + let pendingManualRequest: { token: string; promise: Promise } | undefined; + + const createClientInputRequest = () => { + const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + activeTokens.add(token); + + const promise = new Promise((resolve, reject) => { + loginCallbacks.set(token, { + resolve: (value) => { + activeTokens.delete(token); + loginCallbacks.delete(token); + resolve(value); + }, + reject: (error) => { + activeTokens.delete(token); + loginCallbacks.delete(token); + reject(error); + }, + }); + }); + + return { token, promise }; + }; + + const getManualInputRequest = () => { + if (!pendingManualRequest) { + pendingManualRequest = createClientInputRequest(); + pendingManualRequest.promise + .finally(() => { + pendingManualRequest = undefined; + }) + .catch(() => {}); + } + return pendingManualRequest; + }; + + // Cleanup: remove pending token and abort any waiting promise + const cleanup = () => { + for (const token of activeTokens) { + loginCallbacks.get(token)?.reject(new Error("Login cancelled")); + loginCallbacks.delete(token); + } + activeTokens.clear(); + }; + + // Also cancel on client disconnect + abort.signal.addEventListener("abort", cleanup); + + try { + await modelRuntime.login(provider, "oauth", { + prompt: async (prompt: AuthPrompt) => { + const request = prompt.type === "manual_code" + ? getManualInputRequest() + : createClientInputRequest(); + if (prompt.type === "select") { + send(controller, { + type: "select_request", + message: prompt.message, + options: prompt.options, + token: request.token, + }); + } else { + send(controller, { + type: "prompt_request", + message: prompt.message, + placeholder: prompt.placeholder ?? null, + token: request.token, + }); + } + return request.promise; + }, + notify: (event: AuthEvent) => { + if (event.type === "auth_url") { + const request = getManualInputRequest(); + send(controller, { + type: "auth", + url: event.url, + instructions: event.instructions ?? null, + token: request.token, + }); + } else if (event.type === "device_code") { + send(controller, { + type: "device_code", + userCode: event.userCode, + verificationUri: event.verificationUri, + intervalSeconds: event.intervalSeconds ?? null, + expiresInSeconds: event.expiresInSeconds ?? null, + }); + } else { + send(controller, { type: "progress", message: event.message }); + } + }, + signal: abort.signal, + }); + + invalidateModelsCache(); + send(controller, { type: "success" }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg !== "Login cancelled") { + send(controller, { type: "error", message: msg }); + } else { + send(controller, { type: "cancelled" }); + } + } finally { + cleanup(); + controller.close(); + } + }, + cancel() { + abort.abort(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }); + + // POST /api/auth/logout/:provider + app.post("/api/auth/logout/:provider", async (c) => { + const provider = c.req.param("provider"); + const modelRuntime = await ModelRuntime.create(); + if (!modelRuntime.getProvider(provider)?.auth.oauth) { + return c.json({ error: `Unknown provider: ${provider}` }, 400); + } + await modelRuntime.logout(provider); + invalidateModelsCache(); + return c.json({ ok: true }); + }); + + // GET /api/auth/api-key/:provider — returns auth status (never returns the actual key) + app.get("/api/auth/api-key/:provider", async (c) => { + const provider = c.req.param("provider"); + const modelRuntime = await ModelRuntime.create(); + const status = modelRuntime.getProviderAuthStatus(provider); + const displayName = modelRuntime.getProvider(provider)?.name ?? provider; + const models = modelRuntime.getModels(provider).length; + return c.json({ provider, displayName, configured: status.configured, source: status.source, models }); + }); + + // POST /api/auth/api-key/:provider body: { apiKey: string } + app.post("/api/auth/api-key/:provider", async (c) => { + const provider = c.req.param("provider"); + try { + const { apiKey } = await c.req.json() as { apiKey?: string }; + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return c.json({ error: "apiKey is required" }, 400); + } + const modelRuntime = await ModelRuntime.create(); + let keySubmitted = false; + await modelRuntime.login(provider, "api_key", { + notify: () => {}, + prompt: async (prompt) => { + if (prompt.type === "select") { + const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token"); + if (keyOption) return keyOption.id; + throw new Error(`${provider} requires interactive authentication setup`); + } + if (!keySubmitted && prompt.type === "secret") { + keySubmitted = true; + return apiKey.trim(); + } + throw new Error(`${provider} requires additional authentication settings`); + }, + }); + invalidateModelsCache(); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // DELETE /api/auth/api-key/:provider — removes stored API key + app.delete("/api/auth/api-key/:provider", async (c) => { + const provider = c.req.param("provider"); + try { + const modelRuntime = await ModelRuntime.create(); + await modelRuntime.logout(provider); + invalidateModelsCache(); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); +} diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts new file mode 100644 index 0000000..87c3ea5 --- /dev/null +++ b/server/src/routes/config.ts @@ -0,0 +1,598 @@ +import type { Hono } from "hono"; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { + DefaultPackageManager, + getAgentDir, + SettingsManager, + type PackageSource, + type ResolvedPaths, + type ResolvedResource, +} from "@earendil-works/pi-coding-agent"; +import { getAllowedFileRoots, isExistingFilePathAllowed } from "../lib/file-access"; +import { hasJsonContentType } from "../lib/request-security"; +import { getProjectTrustStatus } from "../lib/project-trust"; +import { createAppSettingsManager, getManagedRuntimePaths, isManagedPath, isManagedRuntime } from "../lib/app-runtime"; +import type { + ExtensionInfo, + ExtensionsResponse, + PluginDiagnostic, + PluginPackageInfo, + PluginResourceCounts, + PluginResourceInfo, + PluginResourceKind, + PluginScope, + PluginsResponse, +} from "../lib/api-types"; + +function extensionName(path: string): string { + const file = basename(path); + const extension = extname(file); + if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path)); + return extension ? file.slice(0, -extension.length) : file; +} + +function extensionFiles(root: string): string[] { + if (!existsSync(root)) return []; + try { + const stats = statSync(root); + if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : []; + if (!stats.isDirectory()) return []; + } catch { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = join(root, entry.name); + if (entry.isDirectory()) files.push(...extensionFiles(entryPath)); + else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(entryPath); + } + return files; +} + +function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo { + const baseDir = resource.metadata.baseDir ?? dirname(resource.path); + const rel = relative(baseDir, resource.path); + return { + name: extensionName(resource.path), + path: resource.path, + relativePath: rel && !rel.startsWith("..") ? rel : resource.path, + source: scopeOverride === "builtin" ? "resources" : resource.metadata.source, + scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"), + status: resource.enabled ? "enabled" : "disabled", + }; +} + +function blockedInfo(path: string): ExtensionInfo { + return { + name: extensionName(path), + path, + relativePath: relative(dirname(dirname(path)), path), + source: "project", + scope: "project", + status: "blocked", + }; +} + +function byPathHasScope(entries: Map, scope: ExtensionInfo["scope"]): boolean { + for (const entry of entries.values()) { + if (entry.scope === scope) return true; + } + return false; +} + +type PluginAction = "install" | "remove" | "update" | "disable" | "enable"; + +function emptyCounts(): PluginResourceCounts { + return { extensions: 0, skills: 0, prompts: 0, themes: 0 }; +} + +function toPluginScope(scope: string): PluginScope { + return scope === "project" ? "project" : "global"; +} + +function keyFor(source: string, scope: PluginScope): string { + return `${scope}\0${source}`; +} + +function getPackageSource(entry: PackageSource): string { + return typeof entry === "string" ? entry : entry.source; +} + +function isDisabledPackage(entry: PackageSource): boolean { + if (typeof entry === "string") return false; + return ( + Array.isArray(entry.extensions) && entry.extensions.length === 0 && + Array.isArray(entry.skills) && entry.skills.length === 0 && + Array.isArray(entry.prompts) && entry.prompts.length === 0 && + Array.isArray(entry.themes) && entry.themes.length === 0 + ); +} + +function getDisabledPackages(settingsManager: SettingsManager): Map { + const disabled = new Map(); + for (const entry of settingsManager.getGlobalSettings().packages ?? []) { + disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry)); + } + for (const entry of settingsManager.getProjectSettings().packages ?? []) { + disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry)); + } + return disabled; +} + +function setPackageDisabled( + settingsManager: SettingsManager, + source: string, + scope: PluginScope, + disabled: boolean, +): boolean { + const current = scope === "project" + ? settingsManager.getProjectSettings().packages ?? [] + : settingsManager.getGlobalSettings().packages ?? []; + let changed = false; + const next = current.map((entry): PackageSource => { + if (getPackageSource(entry) !== source) return entry; + changed = true; + if (disabled) { + return { + ...(typeof entry === "string" ? { source: entry } : entry), + extensions: [], + skills: [], + prompts: [], + themes: [], + }; + } + return getPackageSource(entry); + }); + if (!changed) return false; + if (scope === "project") settingsManager.setProjectPackages(next); + else settingsManager.setPackages(next); + return true; +} + +function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void { + counts[kind] += 1; +} + +function getResourceName(path: string, kind: PluginResourceKind): string { + const file = basename(path); + const ext = extname(file); + if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path)); + if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) { + if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path)); + return file.slice(0, -ext.length); + } + return file; +} + +function getRelativePath(resource: ResolvedResource): string { + const baseDir = resource.metadata.baseDir; + if (!baseDir) return resource.path; + const rel = relative(baseDir, resource.path); + return rel && !rel.startsWith("..") ? rel : resource.path; +} + +function getConfiguredVersion(source: string): string | undefined { + const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined; + if (npmSpec) { + const lastAt = npmSpec.lastIndexOf("@"); + const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0; + if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined; + return undefined; + } + + if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) { + const lastAt = source.lastIndexOf("@"); + const lastSlash = source.lastIndexOf("/"); + const lastColon = source.lastIndexOf(":"); + if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined; + } + return undefined; +} + +function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } { + if (!installedPath) return {}; + try { + const stats = statSync(installedPath); + const packageJsonPath = stats.isDirectory() + ? join(installedPath, "package.json") + : join(dirname(installedPath), "package.json"); + if (!existsSync(packageJsonPath)) return {}; + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + name?: unknown; + version?: unknown; + }; + return { + packageName: typeof parsed.name === "string" ? parsed.name : undefined, + version: typeof parsed.version === "string" ? parsed.version : undefined, + }; + } catch { + return {}; + } +} + +function collectResource( + resource: ResolvedResource, + kind: keyof PluginResourceCounts, + countsByPackage: Map, + resourcesByPackage: Map, + totals: PluginResourceCounts, +): void { + if (!isManagedPath(resource.path)) return; + if (!resource.enabled || resource.metadata.origin !== "package") return; + const source = resource.metadata.source; + const scope = toPluginScope(resource.metadata.scope); + const key = keyFor(source, scope); + const counts = countsByPackage.get(key) ?? emptyCounts(); + addCount(counts, kind); + addCount(totals, kind); + countsByPackage.set(key, counts); + const resources = resourcesByPackage.get(key) ?? []; + const resourceKind = kind === "extensions" + ? "extension" + : kind === "skills" + ? "skill" + : kind === "prompts" + ? "prompt" + : "theme"; + resources.push({ + kind: resourceKind, + name: getResourceName(resource.path, resourceKind), + path: resource.path, + relativePath: getRelativePath(resource), + }); + resourcesByPackage.set(key, resources); +} + +function collectResources(paths: ResolvedPaths): { + countsByPackage: Map; + resourcesByPackage: Map; + totals: PluginResourceCounts; +} { + const countsByPackage = new Map(); + const resourcesByPackage = new Map(); + const totals = emptyCounts(); + for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals); + for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals); + for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals); + for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals); + return { countsByPackage, resourcesByPackage, totals }; +} + +async function readPlugins(cwd: string): Promise { + const agentDir = getAgentDir(); + const projectTrust = getProjectTrustStatus(cwd, agentDir); + const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted); + const packageManager = new DefaultPackageManager({ + cwd, + agentDir, + settingsManager, + }); + + const diagnostics: PluginDiagnostic[] = []; + let countsByPackage = new Map(); + let resourcesByPackage = new Map(); + let totals = emptyCounts(); + const disabledByPackage = getDisabledPackages(settingsManager); + + try { + const resolved = await packageManager.resolve(async (source) => { + diagnostics.push({ + type: "warning", + source, + message: "Package is configured but not installed yet.", + }); + return "skip"; + }); + ({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved)); + } catch (error) { + diagnostics.push({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + + const packages = packageManager.listConfiguredPackages().map((pkg) => { + const scope = toPluginScope(pkg.scope); + const key = keyFor(pkg.source, scope); + const disabled = disabledByPackage.get(key) ?? false; + const counts = countsByPackage.get(key) ?? emptyCounts(); + const resources = resourcesByPackage.get(key) ?? []; + const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes; + const packageMetadata = readPackageMetadata(pkg.installedPath); + if (!pkg.installedPath) { + diagnostics.push({ + type: "warning", + source: pkg.source, + message: "Configured package path was not found.", + }); + } + return { + source: pkg.source, + scope, + filtered: pkg.filtered, + disabled, + installedPath: pkg.installedPath, + packageName: packageMetadata.packageName, + version: packageMetadata.version, + configuredVersion: getConfiguredVersion(pkg.source), + counts, + resources, + status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing", + } satisfies PluginPackageInfo; + }); + + return { + packages, + totals, + diagnostics, + projectResourcesLoaded: projectTrust.trusted, + }; +} + +function readScope(scope: unknown): PluginScope { + if (isManagedRuntime()) return "global"; + return scope === "project" ? "project" : "global"; +} + +function isRemotePackageSource(source: string): boolean { + return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source); +} + +interface McpConfigFile { + mcpServers: Record>; + settings?: Record; + imports?: string[]; +} + +function getMcpPath(): string { + return join(getAgentDir(), "mcp.json"); +} + +function readMcpJson(): McpConfigFile { + const path = getMcpPath(); + if (!existsSync(path)) return { mcpServers: {} }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile; + if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") { + return { mcpServers: {} }; + } + return parsed; + } catch { + return { mcpServers: {} }; + } +} + +function writeMcpJson(data: McpConfigFile): void { + const path = getMcpPath(); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +interface VisionConfigFile { + backend?: "ollama" | "openai"; + ollama?: { host?: string; model?: string }; + openai?: { baseUrl?: string; apiKey?: string; model?: string }; +} + +function getVisionPath(): string { + return join(getAgentDir(), "vision.json"); +} + +function readVisionJson(): VisionConfigFile { + const path = getVisionPath(); + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile; + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } +} + +function writeVisionJson(data: VisionConfigFile): void { + const path = getVisionPath(); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +export function registerConfigRoutes(app: Hono): void { + app.get("/api/extensions", async (c) => { + const cwd = c.req.query("cwd") ?? null; + if (!cwd) return c.json({ error: "cwd required" }, 400); + + try { + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const agentDir = getAgentDir(); + const trust = getProjectTrustStatus(cwd, agentDir); + const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + const diagnostics: PluginDiagnostic[] = []; + const byPath = new Map(); + + try { + const resolved = await packageManager.resolve(async () => "skip"); + for (const resource of resolved.extensions) { + // Package-provided extensions belong to the plugin panel, not here. + if (resource.metadata.origin !== "top-level") continue; + byPath.set(resource.path, infoFromResource(resource)); + } + } catch (error) { + diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } + + if (isManagedRuntime()) { + const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions"); + const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true }); + for (const resource of builtIn.extensions) { + byPath.set(resource.path, infoFromResource(resource, "builtin")); + } + } + + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + if (trust.requiresTrust && !trust.trusted) { + for (const path of extensionFiles(projectExtensionsRoot)) { + byPath.set(path, blockedInfo(path)); + } + if (byPathHasScope(byPath, "project")) { + diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." }); + } + } + + const scopeOrder: Record = { project: 0, builtin: 1, global: 2 }; + const extensions = [...byPath.values()].sort((a, b) => ( + scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path) + )); + return c.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/mcp", (c) => c.json({ config: readMcpJson(), path: getMcpPath() })); + + app.put("/api/mcp", async (c) => { + try { + const body = (await c.req.json()) as McpConfigFile; + if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) { + return c.json({ error: "mcpServers must be an object" }, 400); + } + // 只保留已知顶层字段,避免写入无关键 + const out: McpConfigFile = { mcpServers: body.mcpServers }; + if (body.settings && typeof body.settings === "object") out.settings = body.settings; + if (Array.isArray(body.imports)) out.imports = body.imports; + writeMcpJson(out); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/vision-config", (c) => c.json({ config: readVisionJson(), path: getVisionPath() })); + + app.put("/api/vision-config", async (c) => { + try { + const body = (await c.req.json()) as VisionConfigFile; + if (!body || typeof body !== "object") { + return c.json({ error: "body must be an object" }, 400); + } + const out: VisionConfigFile = {}; + if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend; + if (body.ollama && typeof body.ollama === "object") { + out.ollama = {}; + if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim(); + if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim(); + } + if (body.openai && typeof body.openai === "object") { + out.openai = {}; + if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim(); + if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim(); + if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim(); + } + writeVisionJson(out); + return c.json({ success: true, path: getVisionPath() }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/plugins", async (c) => { + const cwd = c.req.query("cwd"); + if (!cwd) return c.json({ error: "cwd required" }, 400); + + try { + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + return c.json(await readPlugins(cwd)); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // POST /api/plugins body: { action, source?, scope?, cwd } + app.post("/api/plugins", async (c) => { + // The global security middleware already enforces isApiRequestAllowed. + if (!hasJsonContentType(c.req.raw)) { + return c.json({ error: "Content-Type must be application/json" }, 415); + } + + try { + const body = await c.req.json() as { + action?: PluginAction; + source?: string; + scope?: PluginScope; + cwd?: string; + }; + if (!body.cwd) return c.json({ error: "cwd required" }, 400); + if (!body.action) return c.json({ error: "action required" }, 400); + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const agentDir = getAgentDir(); + const projectTrust = getProjectTrustStatus(body.cwd, agentDir); + const settingsManager = createAppSettingsManager(body.cwd, agentDir, projectTrust.trusted); + const scope = readScope(body.scope); + if (scope === "project" && !projectTrust.trusted) { + return c.json( + { error: "Project resources must be trusted before modifying project plugins" }, + 403, + ); + } + const packageManager = new DefaultPackageManager({ + cwd: body.cwd, + agentDir, + settingsManager, + }); + const source = body.source?.trim(); + const local = scope === "project"; + + if ( + source && + isManagedRuntime() && + !isRemotePackageSource(source) && + !isManagedPath(resolve(body.cwd, source)) + ) { + return c.json( + { error: "Local plugins must be stored inside the integrated application directory" }, + 400, + ); + } + + if (body.action === "install") { + if (!source) return c.json({ error: "source required" }, 400); + await packageManager.installAndPersist(source, { local }); + } else if (body.action === "remove") { + if (!source) return c.json({ error: "source required" }, 400); + await packageManager.removeAndPersist(source, { local }); + } else if (body.action === "update") { + await packageManager.update(source); + } else if (body.action === "disable") { + if (!source) return c.json({ error: "source required" }, 400); + setPackageDisabled(settingsManager, source, scope, true); + await settingsManager.flush(); + } else if (body.action === "enable") { + if (!source) return c.json({ error: "source required" }, 400); + setPackageDisabled(settingsManager, source, scope, false); + await settingsManager.flush(); + } else { + return c.json({ error: `Unsupported action: ${body.action}` }, 400); + } + + return c.json(await readPlugins(body.cwd)); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + }); +} diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts new file mode 100644 index 0000000..6520c1e --- /dev/null +++ b/server/src/routes/files.ts @@ -0,0 +1,708 @@ +import type { Hono } from "hono"; +import fs from "node:fs"; +import path from "node:path"; +import { + getAllowedFileRoots, + isExistingFilePathAllowed, + isFilePathAllowed, + isWindowsAbsolutePath, + normalizeSlashes, +} from "../lib/file-access"; +import { + DOCX_PREVIEW_MAX_BYTES, + IMAGE_PREVIEW_MAX_BYTES, + TEXT_PREVIEW_MAX_BYTES, + documentPreviewKind, + getAudioMime, + getDocumentMime, + getFileExt, + getImageMime, + isAudioPath, + isDocumentPreviewPath, + isImagePath, +} from "../lib/file-types"; +import { resolveDirentIsDirectory } from "../lib/file-dirent"; +import { isFilePathReferencedBySession } from "../lib/session-file-references"; +import { + inspectUploadTargets, + parseUploadConflictStrategy, + validateUploadFileNames, +} from "../lib/file-upload"; +import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "../lib/bounded-form-data"; + +const IGNORED_NAMES = new Set([ + "node_modules", ".git", ".next", "dist", "build", "__pycache__", + ".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache", + "target", "vendor", ".DS_Store", ".git", +]); + +const IGNORED_SUFFIXES = [".pyc"]; + +const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const; +type FileRequestType = typeof FILE_REQUEST_TYPES[number]; +const FILE_REQUEST_TYPE_SET = new Set(FILE_REQUEST_TYPES); +const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES; +const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024; +const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; +// Multipart boundaries and headers are not file bytes, but must be bounded too. +const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024; + +const EXT_TO_LANGUAGE: Record = { + ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", + mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", + go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift", + c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp", + html: "html", htm: "html", css: "css", scss: "css", less: "css", + json: "json", jsonl: "json", yaml: "yaml", yml: "yaml", + toml: "toml", xml: "xml", md: "markdown", mdx: "markdown", + sh: "bash", bash: "bash", zsh: "bash", fish: "bash", + sql: "sql", graphql: "graphql", gql: "graphql", + dockerfile: "dockerfile", tf: "hcl", hcl: "hcl", + env: "bash", gitignore: "bash", txt: "text", + pdf: "pdf", docx: "word", +}; + +function getLanguage(filePath: string): string { + const base = path.basename(filePath).toLowerCase(); + // Special full-name matches + if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile"; + if (base === ".env" || base.startsWith(".env.")) return "bash"; + if (base === "makefile" || base === "gnumakefile") return "makefile"; + const ext = base.split(".").pop() ?? ""; + return EXT_TO_LANGUAGE[ext] ?? "text"; +} + +function filePathFromSegments(segments: string[]): string { + const joined = segments.join("/"); + const slashJoined = normalizeSlashes(joined); + if (isWindowsAbsolutePath(slashJoined)) return slashJoined; + return "/" + joined.replace(/^\/+/, ""); +} + +// Mirrors Next's [...path] catch-all: split the raw pathname first, then +// percent-decode each segment individually. +function segmentsFromRequestUrl(url: string): string[] { + const pathname = new URL(url).pathname; + const suffix = pathname.startsWith("/api/files/") ? pathname.slice("/api/files/".length) : ""; + return suffix.split("/").map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); +} + +function parseFileRequestType(value: string): FileRequestType | null { + return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null; +} + +async function getUploadDirectory(segments: string[]): Promise< + { directory: string } | { response: Response } +> { + const directory = filePathFromSegments(segments); + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(directory, allowedRoots)) { + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + + let stat: fs.Stats; + try { + stat = fs.statSync(directory); + } catch { + return { response: Response.json({ error: "Upload directory not found" }, { status: 404 }) }; + } + if (!stat.isDirectory()) { + return { response: Response.json({ error: "Upload target is not a directory" }, { status: 400 }) }; + } + + // A browsable directory can be a symlink. Resolve both sides before writes + // so a symlink inside an allowed root cannot redirect uploads outside it. + const realDirectory = fs.realpathSync(directory); + const realRoots = new Set(); + for (const root of allowedRoots) { + try { + realRoots.add(fs.realpathSync(root)); + } catch { + // Ignore stale session roots that no longer exist. + } + } + if (!isFilePathAllowed(realDirectory, realRoots)) { + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + + return { directory: realDirectory }; +} + +function parseUploadFileNames(value: unknown): string[] | null { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null; + return value; +} + +async function getWritableFile( + segments: string[], + sessionId: string | null, +): Promise<{ filePath: string } | { response: Response }> { + const filePath = filePathFromSegments(segments); + const allowedRoots = await getAllowedFileRoots(); + const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); + const allowedBySessionReference = + !allowedByRoot && + await isFilePathReferencedBySession(filePath, sessionId); + if (!allowedByRoot && !allowedBySessionReference) { + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + + // The target must already exist as a regular file that resolves inside an + // allowed root. Resolving before the write prevents a symlink (possibly + // swapped in by a race) from redirecting the payload outside the roots, and + // the non-existing-path check above excludes files the session or current + // roots no longer own. + if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) { + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { response: Response.json({ error: "Not found" }, { status: 404 }) }; + } + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return { response: Response.json({ error: "Not a writable file" }, { status: 403 }) }; + } + + return { filePath }; +} + +function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream { + const fileStream = fs.createReadStream(filePath, range); + let closed = false; + + return new ReadableStream({ + start(controller) { + fileStream.on("data", (chunk: Buffer) => { + if (closed) return; + try { + controller.enqueue(new Uint8Array(chunk)); + } catch { + closed = true; + fileStream.destroy(); + } + }); + fileStream.once("end", () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + // The browser may cancel media probes before the file stream ends. + } + }); + fileStream.once("error", (error) => { + if (closed) return; + closed = true; + try { + controller.error(error); + } catch { + // The response was already abandoned by the client. + } + }); + }, + cancel() { + closed = true; + fileStream.destroy(); + }, + }); +} + +function encodeHeaderValue(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => + `%${ch.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + +function getContentDisposition(filePath: string, asDownload = false): string { + const disposition = asDownload ? "attachment" : "inline"; + const fileName = path.basename(filePath); + const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download"; + return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`; +} + +function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response { + const headers = { + "Content-Type": contentType, + "Cache-Control": "no-cache", + "Accept-Ranges": "bytes", + "Content-Disposition": getContentDisposition(filePath, asDownload), + }; + + if (!rangeHeader) { + return new Response(createFileBodyStream(filePath), { + headers: { + ...headers, + "Content-Length": String(stat.size), + }, + }); + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader); + if (!match) { + return new Response(null, { + status: 416, + headers: { + ...headers, + "Content-Range": `bytes */${stat.size}`, + }, + }); + } + + let start = match[1] ? Number(match[1]) : 0; + let end = match[2] ? Number(match[2]) : stat.size - 1; + if (!match[1] && match[2]) { + const suffixLength = Number(match[2]); + start = Math.max(stat.size - suffixLength, 0); + end = stat.size - 1; + } + + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) { + return new Response(null, { + status: 416, + headers: { + ...headers, + "Content-Range": `bytes */${stat.size}`, + }, + }); + } + + end = Math.min(end, stat.size - 1); + const chunkSize = end - start + 1; + return new Response(createFileBodyStream(filePath, { start, end }), { + status: 206, + headers: { + ...headers, + "Content-Length": String(chunkSize), + "Content-Range": `bytes ${start}-${end}/${stat.size}`, + }, + }); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string { + return ` + + + + + + + +
+
${escapeHtml(fileName)}
+${bodyHtml} +
+ +`; +} + +export function registerFileRoutes(app: Hono): void { + app.post("/api/files/*", async (c) => { + // The global security middleware already enforces isApiRequestAllowed. + try { + const segments = segmentsFromRequestUrl(c.req.raw.url); + const type = c.req.query("type") ?? "upload"; + const sessionId = c.req.query("sessionId") ?? null; + + if (type === "write") { + const writable = await getWritableFile(segments, sessionId); + if ("response" in writable) return writable.response; + const { filePath } = writable; + + const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath); + if (readonlyFile) { + return c.json({ error: "This file type cannot be edited as text" }, 400); + } + + const body = await c.req.json().catch(() => null) as { content?: unknown } | null; + if (!body || typeof body.content !== "string") { + return c.json({ error: "content must be a string" }, 400); + } + const content = body.content; + const contentBytes = Buffer.byteLength(content, "utf-8"); + if (contentBytes > MAX_WRITE_BODY_BYTES) { + return c.json( + { error: "File too large to save (>256KB)" }, + 413, + ); + } + + // Write to a temp file in the same directory then rename, so a crash or + // an error mid-write never leaves a truncated file behind. + const directory = path.dirname(filePath); + const tmpPath = path.join( + directory, + `.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`, + ); + try { + fs.writeFileSync(tmpPath, content, "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (writeError) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + return c.json( + { error: writeError instanceof Error ? writeError.message : String(writeError) }, + 500, + ); + } + + const stat = fs.statSync(filePath); + return c.json({ size: stat.size }); + } + + const uploadDirectory = await getUploadDirectory(segments); + if ("response" in uploadDirectory) return uploadDirectory.response; + const { directory } = uploadDirectory; + + if (type === "upload-check") { + const body = await c.req.json().catch(() => null) as { fileNames?: unknown } | null; + const fileNames = parseUploadFileNames(body?.fileNames); + if (!fileNames) { + return c.json({ error: "fileNames must be an array of strings" }, 400); + } + const validationError = validateUploadFileNames(fileNames); + if (validationError) { + return c.json({ error: validationError }, 400); + } + return c.json(inspectUploadTargets(directory, fileNames)); + } + + if (type !== "upload") { + return c.json({ error: "Invalid upload request type" }, 400); + } + + const strategy = parseUploadConflictStrategy(c.req.query("conflict") ?? null); + if (!strategy) { + return c.json({ error: "Invalid conflict strategy" }, 400); + } + + let formData: FormData; + try { + formData = await parseFormDataWithinLimit(c.req.raw, MAX_UPLOAD_REQUEST_BYTES); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return c.json({ error: "Uploads must total 100MB or less" }, 413); + } + throw error; + } + const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string"); + if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) { + return c.json({ error: "Each upload must be 25MB or smaller" }, 413); + } + if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) { + return c.json({ error: "Uploads must total 100MB or less" }, 413); + } + const fileNames = files.map((file) => file.name); + const validationError = validateUploadFileNames(fileNames); + if (validationError) { + return c.json({ error: validationError }, 400); + } + + const inspection = inspectUploadTargets(directory, fileNames); + if (strategy === "error" && inspection.conflicts.length > 0) { + return c.json({ + error: "One or more files already exist", + conflicts: inspection.conflicts, + nonReplaceable: inspection.nonReplaceable, + }, 409); + } + + const conflictSet = new Set(inspection.conflicts); + const nonReplaceableSet = new Set(inspection.nonReplaceable); + const uploaded: string[] = []; + const skipped: string[] = []; + const errors: Array<{ name: string; error: string }> = []; + + for (const file of files) { + const destination = path.join(directory, file.name); + if (conflictSet.has(file.name) && strategy === "skip") { + skipped.push(file.name); + continue; + } + if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) { + errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" }); + continue; + } + + let bytes: Buffer; + try { + bytes = Buffer.from(await file.arrayBuffer()); + } catch (error) { + errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); + continue; + } + + if (conflictSet.has(file.name)) { + try { + fs.unlinkSync(destination); + } catch (error) { + errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); + continue; + } + } + + try { + fs.writeFileSync(destination, bytes, { flag: "wx" }); + uploaded.push(file.name); + } catch (error) { + errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) }); + } + } + + return c.json( + { uploaded, skipped, errors }, + errors.length > 0 ? 207 : 200, + ); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + }); + + app.get("/api/files/*", async (c) => { + try { + const segments = segmentsFromRequestUrl(c.req.raw.url); + const filePath = filePathFromSegments(segments); + const rawType = c.req.query("type") ?? "list"; + const type = parseFileRequestType(rawType); + if (!type) { + return c.json({ error: "Invalid file request type" }, 400); + } + const sessionId = c.req.query("sessionId") ?? null; + + const allowedRoots = await getAllowedFileRoots(); + const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); + const allowedBySessionReference = + !allowedByRoot && + type !== "list" && + await isFilePathReferencedBySession(filePath, sessionId); + if (!allowedByRoot && !allowedBySessionReference) { + return c.json({ error: "Access denied" }, 403); + } + + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return c.json({ error: "Not found" }, 404); + } + + if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + if (type === "read") { + if (!stat.isFile()) { + return c.json({ error: "Not a file" }, 400); + } + const imageMime = getImageMime(filePath); + if (imageMime) { + if (stat.size > IMAGE_PREVIEW_MAX_BYTES) { + return c.json({ error: "Image too large (>10MB)" }, 413); + } + return streamFile(filePath, stat, imageMime, c.req.raw.headers.get("range")); + } + const audioMime = getAudioMime(filePath); + if (audioMime) { + return streamFile(filePath, stat, audioMime, c.req.raw.headers.get("range")); + } + const documentMime = getDocumentMime(filePath); + if (documentMime) { + return streamFile(filePath, stat, documentMime, c.req.raw.headers.get("range")); + } + if (stat.size > TEXT_PREVIEW_MAX_BYTES) { + return c.json({ error: "File too large for preview (>256KB)" }, 413); + } + const content = fs.readFileSync(filePath, "utf-8"); + const language = getLanguage(filePath); + return c.json({ content, language, size: stat.size }); + } + + if (type === "download") { + if (!stat.isFile()) { + return c.json({ error: "Not a file" }, 400); + } + const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream"; + return streamFile(filePath, stat, mime, c.req.raw.headers.get("range"), true); + } + + if (type === "meta") { + if (!stat.isFile()) { + return c.json({ error: "Not a file" }, 400); + } + const imageMime = getImageMime(filePath); + const audioMime = getAudioMime(filePath); + const documentMime = getDocumentMime(filePath); + return c.json({ + size: stat.size, + language: getLanguage(filePath), + mime: imageMime || audioMime || documentMime || "text/plain", + previewKind: documentPreviewKind(filePath), + }); + } + + if (type === "preview") { + if (!stat.isFile()) { + return c.json({ error: "Not a file" }, 400); + } + if (getFileExt(filePath) !== "docx") { + return c.json({ error: "Preview not available for this file type" }, 400); + } + if (stat.size > DOCX_PREVIEW_MAX_BYTES) { + return c.json({ error: "DOCX too large for preview (>10MB)" }, 413); + } + + const mammoth = await import("mammoth"); + const result = await mammoth.convertToHtml( + { path: filePath }, + { + externalFileAccess: false, + convertImage: mammoth.images.dataUri, + } + ); + const html = wrapDocxPreviewHtml(result.value, path.basename(filePath)); + return new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + }); + } + + if (type === "watch") { + if (!stat.isFile()) { + return c.json({ error: "Not a file" }, 400); + } + let watcher: fs.FSWatcher | null = null; + let lastMtimeMs = stat.mtimeMs; + let lastSize = stat.size; + const stream = new ReadableStream({ + start(controller) { + const send = (eventName: string, data: Record) => { + const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; + try { + controller.enqueue(new TextEncoder().encode(payload)); + } catch { + // client disconnected + } + }; + // Send initial ping so client knows connection is live + send("connected", { filePath }); + try { + watcher = fs.watch(filePath, () => { + try { + const s = fs.statSync(filePath); + // Some platforms emit watch events for file reads/attribute + // access. Ignore those or the client's refresh read loops. + if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return; + lastMtimeMs = s.mtimeMs; + lastSize = s.size; + send("change", { mtime: s.mtime.toISOString(), size: s.size }); + } catch { + send("change", { mtime: new Date().toISOString(), size: 0 }); + } + }); + watcher.on("error", () => { + try { controller.close(); } catch { /* ignore */ } + }); + } catch { + send("error", { message: "Failed to watch file" }); + controller.close(); + } + }, + cancel() { + try { watcher?.close(); } catch { /* ignore */ } + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); + } + + // type === "list" + if (!stat.isDirectory()) { + return c.json({ error: "Not a directory" }, 400); + } + + // Avoid per-entry stat calls for normal files and directories. Symlinks and + // filesystems without directory type information use the stat fallback. + const dirents = fs.readdirSync(filePath, { withFileTypes: true }); + const entries = dirents + .filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) + .flatMap((d) => { + const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name)); + return isDir === null + ? [] + : [{ name: d.name, isDir, size: 0, modified: "" }]; + }) + .sort((a, b) => { + // Dirs first, then files, both alphabetically + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return c.json({ entries, path: filePath }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); +} diff --git a/server/src/routes/misc.ts b/server/src/routes/misc.ts new file mode 100644 index 0000000..039959b --- /dev/null +++ b/server/src/routes/misc.ts @@ -0,0 +1,428 @@ +import type { Hono } from "hono"; +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { mkdirSync, statSync, type Stats } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import path from "node:path"; +import { isAbsolute, resolve } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { + getBrowseStartDirectory, + getParentDirectory, + listDirectories, + resolveDirectory, +} from "../lib/directory-browser"; +import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "../lib/file-access"; +import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "../lib/file-fuzzy"; +import { getManagedRuntimePaths, isManagedRuntime } from "../lib/app-runtime"; +import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "../lib/worktree"; +import { invalidateModelsCache } from "../lib/models-cache"; +import { getProjectTrustStatus, trustProject } from "../lib/project-trust"; +import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "../lib/rpc-manager"; + +const execFileAsync = promisify(execFile); + +// Same skip lists as /api/files — only used for the non-git readdir fallback. +// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior). +const IGNORED_NAMES = new Set([ + "node_modules", ".git", ".next", "dist", "build", "__pycache__", + ".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache", + "target", "vendor", ".DS_Store", +]); + +const IGNORED_SUFFIXES = [".pyc"]; + +/** Cap on the plain (no-query) response used as the client-side index */ +const MAX_FILES = 5000; +/** Hard caps on the full in-memory listing that ?q= searches against */ +const GIT_HARD_CAP = 200_000; +const WALK_HARD_CAP = 50_000; +const MAX_WALK_DEPTH = 8; +const MAX_QUERY_LENGTH = 500; +const CACHE_TTL_MS = 10_000; +const CACHE_MAX_ENTRIES = 20; + +interface FileListing { + /** Full listing up to the hard cap (not the client cap) */ + files: string[]; + /** True when even the hard cap was exceeded */ + hardTruncated: boolean; +} + +interface CacheEntry { + listing: FileListing; + /** Derived lazily on the first ?q= search against this listing */ + entries?: FileIndexEntry[]; + expiresAt: number; +} + +// Module-level cache: the @ menu re-requests on every open and searches on +// every keystroke, so listings must not be recomputed within a short window. +let fileIndexCache: Map | undefined; + +function getIndexCache(): Map { + if (!fileIndexCache) fileIndexCache = new Map(); + return fileIndexCache; +} + +async function listWithGit(cwd: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], + { timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, + ); + const all = stdout.split("\0").filter(Boolean); + if (all.length > GIT_HARD_CAP) { + return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true }; + } + return { files: all, hardTruncated: false }; + } catch { + // Not a git repo (or git unavailable) — caller falls back to readdir walk. + return null; + } +} + +function listWithWalk(cwd: string): FileListing { + const files: string[] = []; + // BFS so shallow files win when the cap truncates the listing. + const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }]; + while (queue.length > 0) { + const { abs, rel, depth } = queue.shift()!; + let dirents: fs.Dirent[]; + try { + dirents = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + continue; + } + for (const d of dirents) { + if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue; + const childRel = rel ? `${rel}/${d.name}` : d.name; + if (d.isDirectory()) { + if (depth + 1 <= MAX_WALK_DEPTH) { + queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 }); + } + } else if (d.isFile()) { + if (files.length >= WALK_HARD_CAP) { + return { files, hardTruncated: true }; + } + files.push(childRel); + } + } + } + return { files, hardTruncated: false }; +} + +function normalizeCwd(cwd: string): string { + if (cwd === "~") return homedir(); + if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2)); + return isAbsolute(cwd) ? cwd : resolve(cwd); +} + +export function registerMiscRoutes(app: Hono): void { + app.get("/api/home", (c) => c.json({ home: homedir() })); + + app.get("/api/runtime", (c) => { + if (!isManagedRuntime()) { + return c.json({ managed: false, agentDir: getAgentDir() }); + } + + const paths = getManagedRuntimePaths(); + return c.json({ + managed: true, + appRoot: paths.appRoot, + dataDir: paths.dataDir, + agentDir: paths.agentDir, + resourcesDir: paths.resourcesDir, + skillRoots: paths.managedSkillRoots, + }); + }); + + // POST /api/default-cwd + // Managed app: creates /workspaces/default. + // Standalone pi-web: preserves the upstream ~/pi-cwd- behavior. + app.post("/api/default-cwd", (c) => { + try { + const dir = isManagedRuntime() + ? path.join(getManagedRuntimePaths().dataDir, "workspaces", "default") + : path.join( + homedir(), + `pi-cwd-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}`, + ); + mkdirSync(dir, { recursive: true }); + allowFileRoot(dir); + return c.json({ cwd: dir }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。 + app.get("/api/cwd/browse", async (c) => { + try { + const requested = c.req.query("path")?.trim(); + const candidate = getBrowseStartDirectory(requested ?? undefined); + + let resolved: string; + try { + resolved = await resolveDirectory(candidate); + } catch { + return c.json({ error: "Directory does not exist" }, 404); + } + + const directoryStat = await stat(resolved); + if (!directoryStat.isDirectory()) { + return c.json({ error: "Path is not a directory" }, 400); + } + + const directories = await listDirectories(resolved); + + return c.json({ + path: resolved, + parentPath: getParentDirectory(resolved), + directories, + }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // POST /api/cwd/validate body: { cwd: string } + // Validates a candidate workspace before the UI selects it. + app.post("/api/cwd/validate", async (c) => { + try { + const body = await c.req.json() as { cwd?: unknown }; + const cwd = typeof body.cwd === "string" ? body.cwd.trim() : ""; + + if (!cwd) { + return c.json({ error: "Path is required" }, 400); + } + + const normalizedCwd = normalizeCwd(cwd); + let fileStat: Stats; + try { + fileStat = statSync(normalizedCwd); + } catch { + return c.json({ error: `Directory does not exist: ${cwd}` }, 400); + } + + if (!fileStat.isDirectory()) { + return c.json({ error: `Path is not a directory: ${cwd}` }, 400); + } + + allowFileRoot(normalizedCwd); + return c.json({ success: true, cwd: normalizedCwd }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // GET /api/file-index?cwd=/abs/path[&q=query] + // Without q: { files: string[] (relative to cwd, capped at MAX_FILES), + // truncated: boolean } — the client-side index for local filtering. + // With q: { matches: { path, isDir }[] } — ranked against the FULL listing so + // repos larger than MAX_FILES still find deep files (cap applied after + // matching, like the TUI passing the query to fd). + // Guarded by the same allow-list as /api/files. + app.get("/api/file-index", async (c) => { + try { + const cwd = c.req.query("cwd")?.trim() ?? ""; + if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { + return c.json({ error: "cwd must be an absolute path" }, 400); + } + const query = c.req.query("q")?.slice(0, MAX_QUERY_LENGTH) ?? ""; + + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + let dirStat: fs.Stats; + try { + dirStat = fs.statSync(cwd); + } catch { + return c.json({ error: "Directory not found" }, 404); + } + if (!dirStat.isDirectory()) { + return c.json({ error: "Not a directory" }, 400); + } + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const cache = getIndexCache(); + const now = Date.now(); + let cached = cache.get(cwd); + if (!cached || cached.expiresAt <= now) { + const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd); + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(key); + } + if (cache.size >= CACHE_MAX_ENTRIES) cache.clear(); + cached = { listing, expiresAt: now + CACHE_TTL_MS }; + cache.set(cwd, cached); + } + + if (query) { + cached.entries ??= buildEntriesFromFiles(cached.listing.files); + return c.json({ matches: filterFileEntries(cached.entries, query) }); + } + + const { files, hardTruncated } = cached.listing; + return c.json({ + files: files.slice(0, MAX_FILES), + truncated: hardTruncated || files.length > MAX_FILES, + }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees } + app.get("/api/worktrees", async (c) => { + try { + const cwd = c.req.query("cwd"); + if (!cwd) { + return c.json({ error: "cwd is required" }, 400); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const project = await resolveProject(cwd); + let worktrees: Awaited> = []; + let isGit = true; + try { + // For a removed-worktree cwd (session of a deleted worktree), fall back + // to the inferred project root so the switcher still shows the project. + worktrees = await listWorktrees(fs.existsSync(cwd) ? cwd : project.projectRoot); + } catch { + isGit = false; + } + // Every listed path is a git-verified worktree of this project; allow the + // file explorer to browse them even before they have any session (the + // in-memory allowlist from addWorktree does not survive server restarts). + for (const w of worktrees) allowFileRoot(w.path); + return c.json({ + projectRoot: project.projectRoot, + isGit, + isTopLevel: project.isTopLevel, + worktrees, + }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // POST /api/worktrees body: { cwd, branch } → { path, branch } + app.post("/api/worktrees", async (c) => { + try { + const body = await c.req.json() as { cwd?: string; branch?: string }; + if (!body.cwd || typeof body.cwd !== "string") { + return c.json({ error: "cwd is required" }, 400); + } + if (!body.branch || typeof body.branch !== "string") { + return c.json({ error: "branch is required" }, 400); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(body.cwd, allowedRoots) || !isExistingFilePathAllowed(body.cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + if (!fs.existsSync(body.cwd)) { + return c.json({ error: `Directory does not exist: ${body.cwd}` }, 400); + } + + const result = await addWorktree(body.cwd, body.branch); + return c.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return c.json({ error: message }, 400); + } + }); + + // DELETE /api/worktrees body: { cwd, path, force? } + app.delete("/api/worktrees", async (c) => { + try { + const body = await c.req.json() as { cwd?: string; path?: string; force?: boolean }; + if (!body.cwd || typeof body.cwd !== "string") { + return c.json({ error: "cwd is required" }, 400); + } + if (!body.path || typeof body.path !== "string") { + return c.json({ error: "path is required" }, 400); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(body.cwd, allowedRoots) || !isExistingFilePathAllowed(body.cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + await removeWorktree(body.cwd, body.path, body.force === true); + return c.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // git refuses to remove dirty worktrees without --force; surface that so + // the UI can offer a force-remove confirmation. + const dirty = /contains modified or untracked files|is dirty/i.test(message); + return c.json({ error: message, dirty }, dirty ? 409 : 400); + } + }); + + // GET /api/project-trust?cwd= + app.get("/api/project-trust", async (c) => { + const result = await validateTrustCwd(c.req.query("cwd") ?? null); + if ("response" in result) return result.response; + return c.json(getProjectTrustStatus(result.cwd, getAgentDir())); + }); + // POST /api/project-trust body: { cwd } + app.post("/api/project-trust", async (c) => { + try { + const body = await c.req.json() as { cwd?: unknown }; + const result = await validateTrustCwd(body.cwd); + if ("response" in result) return result.response; + + const agentDir = getAgentDir(); + const current = getProjectTrustStatus(result.cwd, agentDir); + if (!current.requiresTrust) { + return c.json({ error: "This project has no resources that require trust" }, 409); + } + if (hasBusyRpcSessionForCwd(result.cwd)) { + return c.json({ error: "Wait for the active session to finish before trusting this project" }, 409); + } + + const status = trustProject(result.cwd, agentDir); + invalidateModelsCache(); + destroyRpcSessionsForCwd(result.cwd); + return c.json(status); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : String(error) }, + 500, + ); + } + }); +} + +async function validateTrustCwd(value: unknown): Promise< + { cwd: string } | { response: Response } +> { + if (typeof value !== "string" || !value.trim()) { + return { response: Response.json({ error: "cwd required" }, { status: 400 }) }; + } + + const cwd = resolve(value); + try { + if (!(await stat(cwd)).isDirectory()) { + return { response: Response.json({ error: "cwd must be a directory" }, { status: 400 }) }; + } + } catch { + return { response: Response.json({ error: "Directory does not exist" }, { status: 400 }) }; + } + + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return { response: Response.json({ error: "Access denied" }, { status: 403 }) }; + } + return { cwd }; +} diff --git a/server/src/routes/models.ts b/server/src/routes/models.ts new file mode 100644 index 0000000..6e1a693 --- /dev/null +++ b/server/src/routes/models.ts @@ -0,0 +1,530 @@ +import type { Hono } from "hono"; +import fs from "node:fs"; +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + createAgentSessionServices, + getAgentDir, + ModelRuntime, + type SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat"; +import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "../lib/file-access"; +import { getGitFileDiff, getGitStatus } from "../lib/git-changes"; +import { loadModelsWithCache, invalidateModelsCache, withModelRuntimeError, type ModelsData } from "../lib/models-cache"; +import { projectTrustReloadOptions } from "../lib/project-trust"; +import { createAppSettingsManager, getAppResourceLoaderOptions } from "../lib/app-runtime"; +import { + flattenModelsDevCatalog, + recommendModelCatalogPreset, + searchModelCatalog, + type ModelCatalogEntry, +} from "../lib/model-catalog"; +import { resolveModelDiscoveryAuth } from "../lib/model-discovery-auth"; +import { buildModelsListUrl, parseDiscoveredModels } from "../lib/model-discovery"; +import { hasJsonContentType } from "../lib/request-security"; + +const modelNameCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); + +function compareModelEntries( + a: { id: string; name: string; provider: string }, + b: { id: string; name: string; provider: string }, +): number { + return ( + modelNameCollator.compare(a.name || a.id, b.name || b.id) || + modelNameCollator.compare(a.provider, b.provider) || + modelNameCollator.compare(a.id, b.id) + ); +} + +const THINKING_SUFFIXES = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); + +function stripThinkingSuffix(modelRef: string): string { + const trimmed = modelRef.trim(); + const colonIndex = trimmed.lastIndexOf(":"); + if (colonIndex === -1) return trimmed; + const suffix = trimmed.substring(colonIndex + 1); + return THINKING_SUFFIXES.has(suffix) + ? trimmed.substring(0, colonIndex) + : trimmed; +} + +function filterByExactEnabledModels( + available: readonly T[], + enabledModels: string[] | undefined, +): readonly T[] { + if (!enabledModels || enabledModels.length === 0) return available; + + const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean)); + const visible = available.filter( + (m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id), + ); + return visible.length > 0 ? visible : available; +} + +async function loadModels(cwd: string): Promise { + const nameMap = new Map(); + let modelList: { id: string; name: string; provider: string }[] = []; + let defaultModel: { provider: string; modelId: string } | null = null; + const thinkingLevels: Record = {}; + const thinkingLevelMaps: Record> = {}; + + const agentDir = getAgentDir(); + // Gate untrusted project extensions: enumerating models still imports and + // runs a repository's .pi/extensions factories, so honor project trust here + // too (see lib/project-trust.ts, #236). + const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir); + const services = await createAgentSessionServices({ + cwd, + agentDir, + settingsManager: createAppSettingsManager(cwd, agentDir), + resourceLoaderOptions: getAppResourceLoaderOptions(), + ...(trustReloadOptions + ? { resourceLoaderReloadOptions: trustReloadOptions } + : {}), + }); + const available = await services.modelRuntime.getAvailable(); + const modelError = services.modelRuntime.getError(); + const settings: SettingsManager = services.settingsManager; + const enabledModels = settings.getEnabledModels(); + const visible = filterByExactEnabledModels(available, enabledModels); + modelList = visible + .map( + (m: { + id: string; + name: string; + provider: string; + serviceTier?: string; + }) => ({ + id: m.id, + name: m.name, + provider: m.provider, + serviceTier: m.serviceTier, + }), + ) + .sort(compareModelEntries); + for (const m of visible) { + const key = `${m.provider}:${m.id}`; + nameMap.set(key, m.name); + thinkingLevels[key] = getSupportedThinkingLevels(m); + if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap; + } + + const provider = settings.getDefaultProvider(); + const modelId = settings.getDefaultModel(); + if ( + provider && + modelId && + visible.some((m) => m.provider === provider && m.id === modelId) + ) { + defaultModel = { provider, modelId }; + } + + return withModelRuntimeError( + { + models: Object.fromEntries(nameMap), + modelList, + defaultModel, + thinkingLevels, + thinkingLevelMaps, + }, + modelError, + ); +} + +const EMPTY_MODELS: ModelsData = { + models: {}, + modelList: [], + defaultModel: null, + thinkingLevels: {}, + thinkingLevelMaps: {}, +}; + +function getModelsPath(): string { + return join(getAgentDir(), "models.json"); +} + +function readModelsJson(): Record { + const path = getModelsPath(); + if (!existsSync(path)) return { providers: {} }; + try { + return JSON.parse(readFileSync(path, "utf8")) as Record; + } catch { + return { providers: {} }; + } +} + +function writeModelsJson(data: Record): void { + const path = getModelsPath(); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2), "utf8"); +} + +const MODELS_DEV_URL = "https://models.dev/api.json"; +const CATALOG_TTL_MS = 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 15_000; + +interface CatalogCache { + entries: ModelCatalogEntry[]; + expiresAt: number; + inFlight?: Promise; +} + +let modelsDevCatalogCache: CatalogCache | undefined; + +function getCatalogCache(): CatalogCache { + return modelsDevCatalogCache ??= { entries: [], expiresAt: 0 }; +} + +async function fetchCatalog(): Promise { + const response = await fetch(MODELS_DEV_URL, { + cache: "no-store", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`); + const entries = flattenModelsDevCatalog(await response.json()); + if (entries.length === 0) throw new Error("models.dev returned an empty catalog"); + return entries; +} + +async function loadCatalog(): Promise { + const cache = getCatalogCache(); + if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries; + if (!cache.inFlight) { + cache.inFlight = fetchCatalog().then((entries) => { + cache.entries = entries; + cache.expiresAt = Date.now() + CATALOG_TTL_MS; + return entries; + }).finally(() => { + cache.inFlight = undefined; + }); + } + + try { + return await cache.inFlight; + } catch (error) { + if (cache.entries.length > 0) return cache.entries; + throw error; + } +} + +const DISCOVERY_TIMEOUT_MS = 20_000; +const TEST_TIMEOUT_MS = 20_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasHeader(headers: Headers, name: string): boolean { + return headers.has(name); +} + +function buildDiscoveryHeaders(api: string, apiKey: string | undefined, configured: Record): Headers { + const headers = new Headers(configured); + if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json"); + if (!apiKey) return headers; + + if (api === "anthropic-messages") { + if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey); + if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01"); + } else if (api === "google-generative-ai") { + if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey); + } else if (!hasHeader(headers, "authorization")) { + headers.set("Authorization", `Bearer ${apiKey}`); + } + return headers; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getAssistantText(message: AssistantMessage): string { + return message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); +} + +export function registerGitAndModelRoutes(app: Hono): void { + app.get("/api/git/diff", async (c) => { + try { + const cwd = c.req.query("cwd")?.trim() ?? ""; + const filePath = c.req.query("path")?.trim() ?? ""; + if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { + return c.json({ error: "cwd must be an absolute path" }, 400); + } + if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) { + return c.json({ error: "path must be an absolute path" }, 400); + } + + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + // The cwd must resolve inside an allowed root. The file itself may no + // longer exist when Git reports it as deleted; getGitFileDiff verifies + // that the requested path belongs to this repository and its status. + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + return c.json(await getGitFileDiff(cwd, filePath)); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + }); + + app.get("/api/git/status", async (c) => { + try { + const cwd = c.req.query("cwd")?.trim() ?? ""; + if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { + return c.json({ error: "cwd must be an absolute path" }, 400); + } + + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + let dirStat: fs.Stats; + try { + dirStat = fs.statSync(cwd); + } catch { + return c.json({ error: "Directory not found" }, 404); + } + if (!dirStat.isDirectory()) { + return c.json({ error: "Not a directory" }, 400); + } + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + return c.json(await getGitStatus(cwd)); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + }); + + app.get("/api/models", async (c) => { + const requestedCwd = c.req.query("cwd") || process.cwd(); + const cwd = resolve(requestedCwd); + + let cwdStat; + try { + cwdStat = await stat(cwd); + } catch { + return c.json({ error: `Directory does not exist: ${cwd}` }, 400); + } + if (!cwdStat.isDirectory()) { + return c.json({ error: `Not a directory: ${cwd}` }, 400); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + try { + return c.json(await loadModelsWithCache(cwd, () => loadModels(cwd))); + } catch { + return c.json(EMPTY_MODELS); + } + }); + + app.get("/api/models-config", (c) => c.json(readModelsJson())); + + app.put("/api/models-config", async (c) => { + try { + const body = await c.req.json() as Record; + writeModelsJson(body); + invalidateModelsCache(); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/models-config/catalog", async (c) => { + const query = (c.req.query("q") ?? "").slice(0, 120); + const provider = (c.req.query("provider") ?? "").slice(0, 120); + const baseUrl = (c.req.query("baseUrl") ?? "").slice(0, 500); + const parsedLimit = Number.parseInt(c.req.query("limit") ?? "50", 10); + const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50; + + try { + const entries = await loadCatalog(); + const models = searchModelCatalog(entries, query, provider, limit); + const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl); + return c.json({ models, recommendation, source: MODELS_DEV_URL }); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); + } + }); + + app.post("/api/models-config/discover", async (c) => { + try { + const body = await c.req.json() as { providerName?: unknown; provider?: unknown }; + const providerName = typeof body.providerName === "string" ? body.providerName.trim() : ""; + if (!providerName) return c.json({ error: "providerName is required" }, 400); + if (!isRecord(body.provider)) return c.json({ error: "provider is required" }, 400); + + const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : ""; + if (!baseUrl) return c.json({ error: "Base URL is required" }, 400); + const api = typeof body.provider.api === "string" && body.provider.api + ? body.provider.api + : "openai-completions"; + + let endpoint: URL; + try { + endpoint = buildModelsListUrl(baseUrl, api); + } catch { + return c.json({ error: "Base URL is invalid" }, 400); + } + + const auth = await resolveModelDiscoveryAuth(providerName, body.provider); + if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) { + return c.json({ error: `No API key found for "${providerName}"` }, 400); + } + + const response = await fetch(endpoint, { + cache: "no-store", + headers: buildDiscoveryHeaders(api, auth.apiKey, auth.headers), + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }); + const responseText = await response.text(); + if (!response.ok) { + return c.json({ + error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`, + status: response.status, + }, 502); + } + + let payload: unknown; + try { + payload = JSON.parse(responseText); + } catch { + return c.json({ error: "Upstream model list was not valid JSON" }, 502); + } + const models = parseDiscoveredModels(payload); + if (models.length === 0) { + return c.json({ error: "No models found in the upstream response" }, 502); + } + + return c.json({ models, endpoint: endpoint.toString() }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500; + return c.json({ error: message }, status); + } + }); + + app.post("/api/models-config/test", async (c) => { + // The global security middleware already enforces isApiRequestAllowed. + if (!hasJsonContentType(c.req.raw)) { + return c.json( + { ok: false, error: "Content-Type must be application/json" }, + 415, + ); + } + + let tempDir: string | undefined; + + try { + const body = await c.req.json() as { providerName?: unknown; provider?: unknown; model?: unknown }; + const providerName = typeof body.providerName === "string" ? body.providerName.trim() : ""; + if (!providerName) return c.json({ ok: false, error: "providerName is required" }, 400); + if (!isRecord(body.provider)) return c.json({ ok: false, error: "provider is required" }, 400); + if (!isRecord(body.model)) return c.json({ ok: false, error: "model is required" }, 400); + + const modelId = typeof body.model.id === "string" ? body.model.id.trim() : ""; + if (!modelId) return c.json({ ok: false, error: "Model ID is required" }, 400); + + tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-")); + const modelsPath = join(tempDir, "models.json"); + writeFileSync(modelsPath, JSON.stringify({ + providers: { + [providerName]: { + ...body.provider, + models: [{ ...body.model, id: modelId }], + }, + }, + }, null, 2), "utf8"); + + const modelRuntime = await ModelRuntime.create({ modelsPath }); + const loadError = modelRuntime.getError(); + if (loadError) return c.json({ ok: false, error: loadError }); + + const model = modelRuntime.getModel(providerName, modelId); + if (!model) return c.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` }); + + const resolved = await modelRuntime.getAuth(model); + if (!resolved?.auth.apiKey) { + return c.json({ ok: false, error: `No API key found for "${providerName}"` }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); + let status: number | undefined; + const startedAt = Date.now(); + + try { + const message = await completeSimple(model, { + messages: [{ + role: "user", + content: "Reply with OK only.", + timestamp: Date.now(), + }], + }, { + apiKey: resolved.auth.apiKey, + headers: resolved.auth.headers, + maxTokens: 16, + timeoutMs: TEST_TIMEOUT_MS, + maxRetries: 0, + cacheRetention: "none", + signal: controller.signal, + onResponse: (response) => { status = response.status; }, + }); + + const latencyMs = Date.now() - startedAt; + if (message.stopReason === "error" || message.stopReason === "aborted") { + return c.json({ + ok: false, + error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"), + latencyMs, + status, + }); + } + + return c.json({ + ok: true, + latencyMs, + status, + responseText: getAssistantText(message).slice(0, 300), + }); + } finally { + clearTimeout(timeout); + } + } catch (error) { + return c.json({ ok: false, error: errorMessage(error) }, 500); + } finally { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + } + }); +} diff --git a/server/src/routes/sessions.ts b/server/src/routes/sessions.ts new file mode 100644 index 0000000..fa537df --- /dev/null +++ b/server/src/routes/sessions.ts @@ -0,0 +1,601 @@ +import type { Hono } from "hono"; +import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join, basename } from "node:path"; +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { tmpdir } from "node:os"; +import { promisify } from "node:util"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent"; +import { + resolveSessionPath, + resolveSessionIdByPath, + invalidateSessionPathCache, + invalidateSessionListCache, + buildSessionContext, + readSessionHeader, + listAllSessions, + getSessionEntries, +} from "../lib/session-reader"; +import { sessionPathKey } from "../lib/session-path"; +import { getRpcSession, getRunningRpcSessionIds, startRpcSession } from "../lib/rpc-manager"; +import { generateSessionTitle } from "../lib/session-title"; + +const execFileAsync = promisify(execFile); + +// BranchNavigator still traverses recursively, so keep the response tree shallow. +const MAX_PROJECTED_TREE_DEPTH = 200; + +/** + * Project the session tree into the shallow navigation tree sent to the client. + * Keeps roots, branch points, and leaves while contracting single-child chains + * without recursive traversal. Contracted entry IDs are attached to the next + * visible node so the UI can still recognize an active leaf inside the chain. + */ +function projectTreeForResponse( + nodes: T[] +): T[] { + const keep = new Set(); + const roots = new Set(nodes); + const seen = new Set(); + const stack = [...nodes]; + + while (stack.length > 0) { + const node = stack.pop()!; + if (seen.has(node)) continue; + seen.add(node); + + if ( + roots.has(node) || + node.children.length !== 1 + ) { + keep.add(node); + } + + for (const child of node.children) { + stack.push(child); + } + } + + const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({ + ...node, + children: [], + ...(compressedEntryIds?.length ? { compressedEntryIds } : {}), + }); + const projectedRoots = nodes.map((node) => cloneNode(node)); + const tasks = nodes.map((source, index) => ({ + source, + projected: projectedRoots[index], + depth: 1, + })); + + const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => { + const pending = [{ node: source, compressedEntryIds: [] as string[] }]; + const flattenedSeen = new Set(); + + while (pending.length > 0) { + const { node, compressedEntryIds } = pending.pop()!; + if (flattenedSeen.has(node)) continue; + flattenedSeen.add(node); + + if (keep.has(node)) { + projectedParent.children.push(cloneNode(node, compressedEntryIds)); + } + + for (let i = node.children.length - 1; i >= 0; i--) { + pending.push({ + node: node.children[i], + compressedEntryIds: keep.has(node) + ? [] + : [...compressedEntryIds, node.entry.id], + }); + } + } + }; + + while (tasks.length > 0) { + const { source, projected, depth } = tasks.pop()!; + + for (const sourceChild of source.children) { + let child = sourceChild; + + if (depth >= MAX_PROJECTED_TREE_DEPTH) { + appendFlattenedKeptDescendants(child, projected); + continue; + } + + const compressedEntryIds: string[] = []; + while (!keep.has(child) && child.children.length === 1) { + compressedEntryIds.push(child.entry.id); + child = child.children[0]; + } + + if (!keep.has(child)) { + continue; + } + + const projectedChild = cloneNode(child, compressedEntryIds); + projected.children.push(projectedChild); + tasks.push({ source: child, projected: projectedChild, depth: depth + 1 }); + } + } + + return projectedRoots; +} + +type PiCodingAgentModule = { + getPackageDir: () => string; +}; + +type ExportHtmlModule = { + exportFromFile: (inputPath: string, outputPath: string) => Promise; +}; + +async function getPiPackageDir(): Promise { + try { + const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule; + return getPackageDir(); + } catch { + return null; + } +} + +function encodeHeaderValue(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => + `%${ch.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + +function getContentDisposition(fileName: string, inline: boolean): string { + const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html"; + const disposition = inline ? "inline" : "attachment"; + return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`; +} + +async function getPiCliPath(): Promise { + const candidates = new Set(); + const packageDir = await getPiPackageDir(); + + if (packageDir) { + candidates.add(join(packageDir, "dist", "cli.js")); + } + + try { + const resolver = (import.meta as ImportMeta & { + resolve?: (specifier: string) => string | Promise; + }).resolve; + if (typeof resolver === "function") { + const indexUrl = await resolver("@earendil-works/pi-coding-agent"); + candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js")); + } + } catch { + // Some runtimes strip import.meta.resolve. + } + + candidates.add( + join( + process.cwd(), + "node_modules", + "@earendil-works", + "pi-coding-agent", + "dist", + "cli.js" + ) + ); + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Patch the exported HTML to fix recursive functions that overflow + * the call stack on deep linear session trees (e.g., 5000+ entries). + * Replaces sortChildren / mapNodes / markActive with iterative + * equivalents; see the pi-web route for the full root-cause notes. + * Line endings are normalized to LF before matching. + */ +function patchExportHtml(html: string): string { + const n = (s: string) => s.replace(/\r\n/g, "\n"); + html = n(html); + + const replaceRequired = (source: string, name: string, search: string, replacement: string) => { + const normalizedSearch = n(search); + const normalizedReplacement = n(replacement); + const matches = source.split(normalizedSearch).length - 1; + if (matches !== 1) { + throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`); + } + return source.replace(normalizedSearch, normalizedReplacement); + }; + + html = replaceRequired( + html, + "sortChildren", + ` function sortChildren(node) { + node.children.sort((a, b) => + new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() + ); + node.children.forEach(sortChildren); + }`, + ` function sortChildren(root) { + const stack = [root]; + while (stack.length) { + const node = stack.pop(); + node.children.sort((a, b) => + new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() + ); + for (let i = node.children.length - 1; i >= 0; i--) { + stack.push(node.children[i]); + } + } + }` + ); + + html = replaceRequired( + html, + "mapNodes", + ` function mapNodes(node) { + treeNodeMap.set(node.entry.id, node); + node.children.forEach(mapNodes); + } + tree.forEach(mapNodes);`, + ` const stack = [...tree].reverse(); + while (stack.length) { + const node = stack.pop(); + treeNodeMap.set(node.entry.id, node); + for (let i = node.children.length - 1; i >= 0; i--) { + stack.push(node.children[i]); + } + }` + ); + + html = replaceRequired( + html, + "markActive", + ` function markActive(node) { + let has = activePathIds.has(node.entry.id); + for (const child of node.children) { + if (markActive(child)) has = true; + } + containsActive.set(node, has); + return has; + }`, + ` function markActive(root) { + // Post-order traversal using two stacks + const stack1 = [root]; + const stack2 = []; + while (stack1.length) { + const node = stack1.pop(); + stack2.push(node); + for (const child of node.children) { + stack1.push(child); + } + } + while (stack2.length) { + const node = stack2.pop(); + let has = activePathIds.has(node.entry.id); + for (const child of node.children) { + if (containsActive.get(child)) has = true; + } + containsActive.set(node, has); + } + }` + ); + + return html; +} + +async function exportSession(filePath: string, outputPath: string): Promise { + const cliPath = await getPiCliPath(); + if (cliPath) { + await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], { + cwd: process.cwd(), + timeout: 30_000, + env: { + ...process.env, + PI_OFFLINE: "1", + PI_SKIP_VERSION_CHECK: "1", + }, + maxBuffer: 1024 * 1024, + }); + return; + } + + const packageDir = await getPiPackageDir(); + if (!packageDir) throw new Error("pi CLI not found"); + + const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href; + const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule; + await exportFromFile(filePath, outputPath); +} + +export function registerSessionRoutes(app: Hono): void { + app.get("/api/sessions", async (c) => { + try { + const sessions = await listAllSessions(); + return c.json({ sessions, runningSessionIds: getRunningRpcSessionIds() }); + } catch (error) { + return c.json( + { error: String(error) }, + 500 + ); + } + }); + + app.get("/api/sessions/:id", async (c) => { + const id = c.req.param("id"); + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + const sm = SessionManager.open(filePath); + const entries = sm.getEntries() as never; + const leafId = sm.getLeafId(); + const tree = projectTreeForResponse(sm.getTree()); + const url = new URL(c.req.raw.url); + const deferThinking = url.searchParams.has("deferThinking"); + const deferToolResultImages = url.searchParams.has("deferMedia"); + const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages }); + + const header = sm.getHeader(); + let modified = header?.timestamp ?? new Date().toISOString(); + try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ } + const parentSessionId = header?.parentSession + ? await resolveSessionIdByPath(header.parentSession) + : undefined; + const info = header ? { + path: filePath, + id: header.id, + cwd: header.cwd ?? "", + name: sm.getSessionName(), + created: header.timestamp, + modified, + messageCount: context.messages.length, + firstMessage: context.messages.find((m) => m.role === "user") + ? (() => { + const msg = context.messages.find((m) => m.role === "user")!; + const content = (msg as { content: unknown }).content; + return typeof content === "string" ? content : (Array.isArray(content) ? (content.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)"; + })() + : "(no messages)", + parentSessionId, + } : null; + + return c.json({ + sessionId: id, + filePath, + info, + leafId, + tree, + context, + }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // PATCH /api/sessions/:id body: { name: string } + app.patch("/api/sessions/:id", async (c) => { + const id = c.req.param("id"); + try { + const { name } = await c.req.json() as { name?: string }; + if (typeof name !== "string") { + return c.json({ error: "name is required" }, 400); + } + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + const sm = SessionManager.open(filePath); + sm.appendSessionInfo(name.trim()); + invalidateSessionListCache(); + return c.json({ ok: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + // DELETE /api/sessions/:id + app.delete("/api/sessions/:id", async (c) => { + const id = c.req.param("id"); + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + // Read only the bounded header before deleting. + const parentSessionPath = readSessionHeader(filePath)?.parentSession; + + // Re-attach all direct children to this session's parent (cascade re-parent) + // Scan sibling files in the same directory + const targetPathKey = sessionPathKey(filePath); + const dir = dirname(filePath); + try { + const files = readdirSync(dir).filter( + (file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey, + ); + for (const file of files) { + const childPath = join(dir, file); + try { + const content = readFileSync(childPath, "utf8"); + const lines = content.split("\n"); + const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string }; + if ( + header.type === "session" && + header.parentSession && + sessionPathKey(header.parentSession) === targetPathKey + ) { + // Rewrite header with new parentSession + header.parentSession = parentSessionPath; + lines[0] = JSON.stringify(header); + writeFileSync(childPath, lines.join("\n")); + } + } catch { /* skip malformed */ } + } + } catch { /* skip if dir unreadable */ } + + getRpcSession(id)?.destroy(); + unlinkSync(filePath); + invalidateSessionPathCache(id); + invalidateSessionListCache(); + return c.json({ ok: true }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/sessions/:id/state", async (c) => { + const id = c.req.param("id"); + try { + if (!await resolveSessionPath(id)) { + return c.json({ error: "Session not found" }, 404); + } + + const rpc = getRpcSession(id); + if (!rpc?.isAlive()) return c.json({ running: false }); + + const state = await rpc.send({ type: "get_state" }); + return c.json({ running: true, state }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/sessions/:id/context", async (c) => { + const id = c.req.param("id"); + const url = new URL(c.req.raw.url); + const leafId = url.searchParams.get("leafId") ?? undefined; + const deferThinking = url.searchParams.has("deferThinking"); + const deferToolResultImages = url.searchParams.has("deferMedia"); + + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + const sm = SessionManager.open(filePath); + const context = buildSessionContext(sm.getEntries() as never, leafId, { + deferThinking, + deferToolResultImages, + }); + + return c.json({ context }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.post("/api/sessions/:id/auto-name", async (c) => { + const id = c.req.param("id"); + + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd(); + const existing = getRpcSession(id); + const { session } = existing?.isAlive() + ? { session: existing } + : await startRpcSession(id, filePath, cwd); + + // Older wrapper instances may predate waitUntilReady(), but those have + // already completed startup. + await session.waitUntilReady?.(); + const result = await generateSessionTitle(session.inner as unknown as AgentSession); + + if (!session.isAlive()) { + return c.json( + { error: "The session was closed while its title was being generated. Please try again." }, + 409, + ); + } + + session.inner.setSessionName(result.title); + invalidateSessionListCache(); + return c.json({ title: result.title, usage: result.usage ?? null }); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : String(error) }, + 500, + ); + } + }); + + app.get("/api/sessions/:id/entries/:entryId/thinking", async (c) => { + const id = c.req.param("id"); + const entryId = c.req.param("entryId"); + const blockIndexParam = c.req.query("blockIndex") ?? null; + const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam); + if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) { + return c.json({ error: "Valid blockIndex is required" }, 400); + } + + try { + const filePath = await resolveSessionPath(id); + if (!filePath) return c.json({ error: "Session not found" }, 404); + + // SessionManager-backed parsing preserves the SDK's malformed-line tolerance. + const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId); + if (!entry || entry.type !== "message" || entry.message.role !== "assistant") { + return c.json({ error: "Assistant message not found" }, 404); + } + + const block = entry.message.content[blockIndex]; + if (!block || block.type !== "thinking") { + return c.json({ error: "Thinking block not found" }, 404); + } + + return c.json({ thinking: block.thinking }); + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); + + app.get("/api/sessions/:id/export", async (c) => { + const id = c.req.param("id"); + const inline = c.req.query("inline") === "1"; + + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return c.json({ error: "Session not found" }, 404); + } + + const tempDir = join(tmpdir(), "pi-web-export"); + mkdirSync(tempDir, { recursive: true }); + + const sessionBase = basename(filePath, ".jsonl"); + const fileName = `pi-session-${sessionBase}.html`; + const outputPath = join(tempDir, `${randomUUID()}.html`); + + try { + await exportSession(filePath, outputPath); + + const html = readFileSync(outputPath, "utf8"); + const patchedHtml = patchExportHtml(html); + return new Response(patchedHtml, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Disposition": getContentDisposition(fileName, inline), + "Cache-Control": "no-cache", + "Content-Security-Policy": "frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }); + } finally { + rmSync(outputPath, { force: true }); + } + } catch (error) { + return c.json({ error: String(error) }, 500); + } + }); +} diff --git a/server/src/routes/skills.ts b/server/src/routes/skills.ts new file mode 100644 index 0000000..bbd6966 --- /dev/null +++ b/server/src/routes/skills.ts @@ -0,0 +1,340 @@ +import type { Hono } from "hono"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { loadSkillsWithInstallInfo } from "../lib/skills-service"; +import { getAllowedFileRoots, isExistingFilePathAllowed } from "../lib/file-access"; +import { getManagedRuntimePaths, getSkillsCliEnvironment, isManagedRuntime } from "../lib/app-runtime"; +import { checkSkillUpdates, buildSkillUpdateArgs } from "../lib/skill-updates"; +import { runNpx } from "../lib/npx"; +import { hasJsonContentType } from "../lib/request-security"; +import { getProjectTrustStatus } from "../lib/project-trust"; +import type { SkillInstallScope, SkillSearchResult } from "../lib/api-types"; + +const ANSI_RE = /\x1B\[[0-9;]*m/g; +const DEFAULT_LIMIT = 50; +const MIN_LIMIT = 1; +const MAX_LIMIT = 50; +const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh"; + +interface SkillsApiSkill { + id?: string; + name?: string; + source?: string; + installs?: number; +} + +interface SkillsApiResponse { + skills?: SkillsApiSkill[]; +} + +function parseLimit(value: unknown): number { + const num = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(num)) return DEFAULT_LIMIT; + return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num))); +} + +function formatInstalls(count?: number): string { + if (!count || count <= 0) return ""; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; + return `${count} install${count === 1 ? "" : "s"}`; +} + +function parseSearchOutput(raw: string): SkillSearchResult[] { + const clean = raw.replace(ANSI_RE, ""); + const results: SkillSearchResult[] = []; + const lines = clean.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // package line: "owner/repo@skill NNK installs" + const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/); + if (pkgMatch) { + const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, ""); + results.push({ + package: pkgMatch[1], + installs: pkgMatch[2], + url: urlLine?.startsWith("https://") ? urlLine : "", + }); + } + } + return results; +} + +async function searchSkillsApi(query: string, limit: number): Promise { + const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`); + + const data = (await res.json()) as SkillsApiResponse; + return (data.skills ?? []) + .map((skill) => { + const name = skill.name?.trim(); + const source = skill.source?.trim(); + const slug = skill.id?.trim(); + if (!name || (!source && !slug)) return null; + + const pkg = `${source || slug}@${name}`; + return { + package: pkg, + installs: formatInstalls(skill.installs), + url: slug ? `${SEARCH_API_BASE}/${slug}` : "", + }; + }) + .filter((skill): skill is SkillSearchResult => skill !== null) + .sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs)); +} + +function parseInstallCount(installs: string): number { + const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/); + if (!match) return 0; + const value = Number(match[1]); + if (!Number.isFinite(value)) return 0; + const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1; + return value * multiplier; +} + +export function registerSkillRoutes(app: Hono): void { + // GET /api/skills?cwd= + // Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json + // skill paths, package skills, and .agents/skills directories are all included. + app.get("/api/skills", async (c) => { + const cwd = c.req.query("cwd"); + if (!cwd) return c.json({ error: "cwd required" }, 400); + + try { + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + return c.json(await loadSkillsWithInstallInfo(cwd)); + } catch (e) { + return c.json({ error: String(e) }, 500); + } + }); + + // PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file + app.patch("/api/skills", async (c) => { + try { + const body = await c.req.json() as { filePath: string; disableModelInvocation: boolean }; + const { filePath, disableModelInvocation } = body; + if (!filePath) return c.json({ error: "filePath required" }, 400); + if (!existsSync(filePath)) return c.json({ error: "file not found" }, 404); + const allowedRoots = new Set(await getAllowedFileRoots()); + allowedRoots.add(getAgentDir()); + if (isManagedRuntime()) { + for (const root of getManagedRuntimePaths().managedSkillRoots) { + if (existsSync(root)) allowedRoots.add(root); + } + } else { + // Upstream-compatible mode keeps the CLI's user-wide skill root. + const globalSkillsDir = path.join(homedir(), ".agents", "skills"); + if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir); + } + if (!isExistingFilePathAllowed(filePath, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const content = readFileSync(filePath, "utf8"); + const key = "disable-model-invocation"; + + // Use parseFrontmatter to check current value, then do a surgical line edit + // to preserve the original YAML formatting of all other fields. + const { frontmatter } = parseFrontmatter>(content); + const alreadySet = Boolean(frontmatter[key]); + + let updated = content; + if (disableModelInvocation && !alreadySet) { + // Add key after the opening --- line + updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`); + // If no frontmatter exists, create one + if (updated === content) updated = `---\n${key}: true\n---\n${content}`; + } else if (!disableModelInvocation && alreadySet) { + // Remove the key line entirely + updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), ""); + } + + writeFileSync(filePath, updated, "utf8"); + return c.json({ success: true }); + } catch (e) { + return c.json({ error: String(e) }, 500); + } + }); + + app.post("/api/skills/check", async (c) => { + try { + const body = await c.req.json() as { + cwd?: unknown; + package?: unknown; + scope?: unknown; + }; + const cwd = typeof body.cwd === "string" ? body.cwd : ""; + if (!cwd) return c.json({ error: "cwd required" }, 400); + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const pkg = typeof body.package === "string" ? body.package : undefined; + const scope = body.scope === "global" || body.scope === "project" + ? body.scope as SkillInstallScope + : undefined; + if ((pkg && !scope) || (!pkg && scope)) { + return c.json({ error: "package and scope must be provided together" }, 400); + } + + const { skills } = await loadSkillsWithInstallInfo(cwd); + const installs = skills + .map((skill) => skill.install) + .filter((install): install is NonNullable => Boolean(install)) + .filter((install) => !pkg || (install.package === pkg && install.scope === scope)); + + if (pkg && installs.length === 0) { + return c.json({ error: "Installed skill not found" }, 404); + } + + const updates = await checkSkillUpdates(installs, { + githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, + }); + return c.json({ updates }); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : String(error) }, + 500, + ); + } + }); + + // POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string } + app.post("/api/skills/install", async (c) => { + // The global security middleware already enforces isApiRequestAllowed. + if (!hasJsonContentType(c.req.raw)) { + return c.json({ error: "Content-Type must be application/json" }, 415); + } + + try { + const { package: pkg, scope, cwd } = await c.req.json() as { package?: string; scope?: string; cwd?: string }; + if (!pkg?.trim()) return c.json({ error: "package required" }, 400); + + const isGlobal = isManagedRuntime() || scope !== "project"; + if (!isGlobal) { + if (!cwd) return c.json({ error: "cwd required for project install" }, 400); + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) { + return c.json( + { error: "Project resources must be trusted before installing project skills" }, + 403, + ); + } + } + const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"]; + if (isGlobal) args.push("-g"); + + console.log(`[skills/install] running: npx ${args.join(" ")}`); + const { stdout, stderr } = await runNpx(args, { + timeout: 60000, + cwd: !isGlobal && cwd ? cwd : undefined, + env: getSkillsCliEnvironment(), + }); + + const output = (stdout + stderr).replace(ANSI_RE, ""); + const success = /Installation complete|Installed \d+ skill/.test(output); + if (!success) { + return c.json({ error: output.slice(-300) || "Install failed" }, 500); + } + return c.json({ success: true, output }); + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; message?: string }; + const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, ""); + return c.json({ error: output || (err.message ?? String(e)) }, 500); + } + }); + + // POST /api/skills/search body: { query: string, limit?: number } + app.post("/api/skills/search", async (c) => { + try { + const { query, limit: rawLimit } = await c.req.json() as { query?: string; limit?: unknown }; + if (!query?.trim()) return c.json({ error: "query required" }, 400); + const limit = parseLimit(rawLimit); + + try { + const results = await searchSkillsApi(query.trim(), limit); + return c.json({ results }); + } catch { + const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], { + timeout: 20000, + env: { ...process.env, FORCE_COLOR: "0" }, + }); + + const results = parseSearchOutput(stdout + stderr).slice(0, limit); + return c.json({ results }); + } + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; message?: string }; + const raw = (err.stdout ?? "") + (err.stderr ?? ""); + const results = raw ? parseSearchOutput(raw) : []; + if (results.length > 0) return c.json({ results }); + return c.json({ error: err.message ?? String(e) }, 500); + } + }); + + app.post("/api/skills/update", async (c) => { + try { + const body = await c.req.json() as { + cwd?: unknown; + package?: unknown; + scope?: unknown; + }; + const cwd = typeof body.cwd === "string" ? body.cwd : ""; + const pkg = typeof body.package === "string" ? body.package : ""; + const scope = body.scope === "global" || body.scope === "project" + ? body.scope as SkillInstallScope + : undefined; + if (!cwd || !pkg || !scope) { + return c.json({ error: "cwd, package, and scope are required" }, 400); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return c.json({ error: "Access denied" }, 403); + } + + const { skills } = await loadSkillsWithInstallInfo(cwd); + const skill = skills.find( + (item) => item.install?.package === pkg && item.install.scope === scope, + ); + if (!skill?.install) { + return c.json({ error: "Installed skill not found" }, 404); + } + if (!skill.install.canCheckForUpdates) { + return c.json({ error: "This skill cannot be updated automatically" }, 400); + } + + const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), { + timeout: 60_000, + cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined, + env: getSkillsCliEnvironment(), + }); + + const refreshed = await loadSkillsWithInstallInfo(cwd); + const updatedSkill = refreshed.skills.find( + (item) => item.install?.package === pkg && item.install.scope === scope, + ); + return c.json({ + success: true, + skill: updatedSkill, + output: `${stdout}${stderr}`.slice(-500), + }); + } catch (error: unknown) { + const detail = error as { stdout?: string; stderr?: string; message?: string }; + const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`; + return c.json( + { error: output || detail.message || String(error) }, + 500, + ); + } + }); +} diff --git a/server/src/security.ts b/server/src/security.ts new file mode 100644 index 0000000..4685dc0 --- /dev/null +++ b/server/src/security.ts @@ -0,0 +1,12 @@ +import type { MiddlewareHandler } from "hono"; +import { isApiRequestAllowed } from "./lib/request-security.js"; + +// Same gate pi-web's proxy middleware applied to /api/*: loopback/IP-literal +// hosts plus same-origin checks. The frontend proxies /api here server-side, +// so Host/Origin headers arrive unchanged from the browser. +export const securityMiddleware: MiddlewareHandler = async (c, next) => { + if (!isApiRequestAllowed(c.req.raw)) { + return c.json({ error: "Untrusted API request" }, 403); + } + await next(); +}; diff --git a/pi-web/lib/searxng-extension.test.mjs b/server/test/searxng-extension.test.mjs similarity index 100% rename from pi-web/lib/searxng-extension.test.mjs rename to server/test/searxng-extension.test.mjs diff --git a/pi-web/scripts/searxng-smoke.mjs b/server/test/searxng-smoke.mjs similarity index 100% rename from pi-web/scripts/searxng-smoke.mjs rename to server/test/searxng-smoke.mjs diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..7dd334f --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["src"] +}