Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e572fc5d76 | ||
|
|
e6795effe1 | ||
|
|
13e6540cdf | ||
|
|
870c30164b | ||
|
|
997f2c523e | ||
|
|
a24bc16513 | ||
|
|
a4da9b9cbd | ||
|
|
c36b45e9d5 | ||
|
|
c649fdf6ed | ||
|
|
5bf60c7921 | ||
|
|
09cd992aa6 | ||
|
|
c539995b19 | ||
|
|
60c6021390 | ||
|
|
47711a9600 | ||
|
|
506599020c | ||
|
|
5c3fcfdd57 | ||
|
|
dfaf8d1551 | ||
|
|
dad0915d62 | ||
|
|
5438780a55 | ||
|
|
b2a7d1a0f0 | ||
|
|
ee695a64af | ||
|
|
e3520b8eb1 | ||
|
|
c8cc78e8f1 | ||
|
|
f2ca1543d2 | ||
|
|
b7233afd77 | ||
|
|
a458b3c478 | ||
|
|
0ddc822238 | ||
|
|
cb950891d5 | ||
|
|
6fe98887d5 |
@@ -87,11 +87,12 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
|
||||
|
||||
- **Ctrl+O**: print the full text of the last `/compact` (or auto-compact) summary. The collapsed notice you see right after compacting only shows a one-line hint — press Ctrl+O any time afterward to print the whole thing.
|
||||
- **Ctrl+B**: while a `bash` command is running, detaches it into the background and returns control to you immediately — the turn continues with a `bash_output`-checkable job id instead of waiting for the command to finish. A notice appears in the transcript once the backgrounded command actually completes. Only `bash` supports this today. The model can kill a still-running backgrounded job with `bash_kill`; any jobs still running when locode itself exits are killed too, so they don't outlive the process as orphans.
|
||||
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
|
||||
|
||||
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
|
||||
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
||||
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write`, `task_create`, `task_list`, `task_get`, `task_update` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
|
||||
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
|
||||
- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on.
|
||||
- **Structured tasks**: for multi-step work the model can call `task_create`/`task_list`/`task_get`/`task_update` to track units of work with a dependency graph (`blocks`/`blockedBy`), ownership (`owner`), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flat `todo_write` live checklist (`☐`/`◐`/`☑`) remains for simpler cases.
|
||||
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
|
||||
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
|
||||
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
|
||||
@@ -100,10 +101,12 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
|
||||
- **MCP servers**: locode connects to any [MCP](https://modelcontextprotocol.io) servers configured via `locode mcp add` or a project's `.mcp.json` (stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced as `mcp__<server>__<tool>`. Every MCP tool is treated as mutating (confirmation required on every call) regardless of what it reports — the MCP `readOnlyHint` annotation is advisory and could be wrong (or set by a malicious server specifically to skip confirmation), so locode never trusts it. One misconfigured server doesn't block the others — check `/mcp` for per-server connection status.
|
||||
- **Claude Code plugins**: `locode plugin add <path-or-git-url>` installs a Claude Code-compatible plugin — locode reads its `.claude-plugin/plugin.json`, then loads it all directly: MCP servers (its `.mcp.json` or manifest `mcpServers`, merged in like any other MCP server), slash commands (`commands/*.md` — frontmatter `description`/`argument-hint`, body is a template expanded with `$ARGUMENTS`/`$1..$9` and submitted as your message), agents (`agents/*.md` — the body becomes a sub-agent's system prompt, exposed as a callable tool named `agent__<plugin>__<agent>`; a `tools:` frontmatter list restricts what it can use, with Claude Code's built-in tool names — Read, Grep, Edit, etc. — automatically mapped to locode's equivalents), hooks (`hooks/hooks.json`, see below), and skills (`skills/*/SKILL.md`, see below). Check `/plugins` for what's loaded.
|
||||
- **Skills**: named instructions the model loads on demand rather than a hook or a sub-agent — every installed skill (`skills/<name>/SKILL.md`) is exposed through one shared `skill` tool, whose own description lists every skill's name and "use this when..." blurb so the model knows when to call it. You can also invoke one directly with `/<skill-name> [request]`, which skips the model's own judgment and submits the skill's instructions (plus your request, if any) as the turn. Check `/skills` for what's installed.
|
||||
- **Hooks**: shell commands that fire on session lifecycle events — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. Configured the same way MCP servers are — plugin-bundled (`hooks/hooks.json`), user-level (`locode hooks path`, hand-edited), and project-level (`.locode/hooks.json`) all merge together, every hook from every source runs. A hook receives a JSON payload on stdin (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields like `prompt` or `tool_name`/`tool_input`); exit 0 allows (stdout becomes injected context for `SessionStart`/`UserPromptSubmit`), exit 2 blocks (stderr is the reason shown), anything else is a non-blocking warning. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `PreToolUse` fires before permission modes apply, so a hook's block can't be bypassed by auto-accept. `command` and `http` hook types are supported (`prompt` is declared in the config format but not yet executed); command hooks can opt into structured JSON output via `outputSchema: "json"`. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`). Check `/hooks` for what's configured.
|
||||
- **Hooks**: shell commands that fire on session lifecycle events — `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. Configured the same way MCP servers are — plugin-bundled (`hooks/hooks.json`), user-level (`locode hooks path`, hand-edited), and project-level (`.locode/hooks.json`) all merge together, every hook from every source runs. A hook receives a JSON payload on stdin (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields like `prompt` or `tool_name`/`tool_input`); exit 0 allows (stdout becomes injected context for `SessionStart`/`UserPromptSubmit`), exit 2 blocks (stderr is the reason shown), anything else is a non-blocking warning. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `PreToolUse` fires before permission modes apply, so a hook's block can't be bypassed by auto-accept. `command` and `http` hook types run an external process/request; a `prompt` hook just injects its static `message` as additional context instead. Command/http hooks can opt into structured JSON output via `outputSchema: "json"`. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`). Check `/hooks` for what's configured.
|
||||
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
|
||||
- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow <n>`, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`.
|
||||
|
||||
- **Code intelligence (LSP)**: `definition`, `references`, and `diagnostics` use a real language server (LSP) for go-to-definition, find-all-references, and type/syntax error checks — the same engine an editor's Problems panel uses, more precise than `grep`. A server is lazily started per language on first use and reused for the whole session: `typescript-language-server` (TypeScript/JavaScript), `pyright-langserver` (Python), `gopls` (Go), `rust-analyzer` (Rust), and `clangd` (C/C++ — one clangd covers both). The relevant server binary must be on your PATH; if it isn't, the tool returns a clear "install X" error. After any edit, locode syncs the file to the live server so a subsequent `diagnostics` call reflects the change (it waits for the server to publish fresh diagnostics rather than reading a stale snapshot). Add or override servers with `locode config set lspServers` (see Config).
|
||||
|
||||
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
|
||||
|
||||
## Slash commands
|
||||
@@ -112,6 +115,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
|
||||
/model <name> switch the model used for the current backend
|
||||
/backend <name> switch backend (ollama | lmstudio), keeps current model
|
||||
/mode <name> view or force tool-call mode (native | fallback)
|
||||
/mouse [on|off] toggle mouse tracking (on by default; hold Shift+click/drag for native text selection)
|
||||
/perm [mode] cycle or set permission mode (default | plan | auto-edit | auto-accept)
|
||||
/status show current model, backend, tool-call mode, and cwd
|
||||
/dashboard show session stats: token I/O, elapsed/model time, turns, tool calls
|
||||
@@ -123,7 +127,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
|
||||
/hooks show configured hooks per lifecycle event
|
||||
/skills show installed skills; /<skill-name> [request] invokes one directly
|
||||
/compact summarize the conversation now to free up context
|
||||
/export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)
|
||||
/export [file] save the conversation as markdown (or /export json [file] for a full JSON dump incl. tool calls/results)
|
||||
/import <path> [caption] attach a local file or image to your next message
|
||||
/clear clear conversation history
|
||||
/help show this help
|
||||
@@ -143,6 +147,11 @@ locode config set autoCompactThreshold 0.85 # fraction of context window at whi
|
||||
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
|
||||
# your backend queues requests behind a concurrency limit
|
||||
# (e.g. Ollama's OLLAMA_NUM_PARALLEL) under multi-session load
|
||||
locode config set lspServers '{"java":{"command":"jdtls","extensions":[".java"]}}' # add a language server
|
||||
# (JSON object keyed by language id; built-in ids
|
||||
# override command/args, new ids add support and
|
||||
# require extensions). Built-ins: typescript,
|
||||
# python, go, rust, c (C/C++ share clangd).
|
||||
locode config get
|
||||
locode config path
|
||||
```
|
||||
@@ -151,13 +160,12 @@ locode config path
|
||||
|
||||
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
|
||||
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
|
||||
- No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand.
|
||||
- Session resume replays prior user/assistant text so you can see it, but it doesn't re-display prior tool-call/tool-result lines from before the resume (the model still has that history — it's just not re-rendered).
|
||||
- No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read).
|
||||
- No OS-level sandboxing (no container/VM isolation) — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand. Two lightweight guardrails run unconditionally regardless of permission mode (including `auto-accept`), as a safety floor rather than a full sandbox: `write_file`/`edit_file`/`bash`'s `cwd` override can't target a path outside the working directory (`../` traversal, an absolute path elsewhere, or — on Windows — a different drive all refuse), and `bash` refuses a short list of unambiguously catastrophic commands (wiping the filesystem root or home directory, a fork bomb, formatting/wiping a whole drive, writing raw data to a block device) before they'd ever run. Neither guard stops a model from doing damage confined to *within* the project directory, or running something merely inadvisable — see `src/tools/pathGuard.ts` and `src/tools/bashGuard.ts`.
|
||||
- In-app scrollback: mouse wheel scrolls the conversation view when mouse tracking is on (the default). PageUp/PageDown also scroll a page at a time. Hold Shift+click/drag for native terminal text selection and copy (when mouse tracking is on). Scrolling back up unpins the view from the latest message; scrolling back to the bottom (or sending a new message) re-pins it so new messages auto-scroll into view. Toggle mouse tracking with `/mouse on|off`.
|
||||
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
|
||||
- `git_commit` covers add/commit/create_branch/checkout/push/reset/stash/merge/rebase/delete_branch. Use `bash` for anything beyond that.
|
||||
- MCP tool results support text, image, audio, and resource content blocks. Images are returned in the same shape as `read_file` so vision-capable models can see them; audio and binary resources are summarized. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
|
||||
- Compaction (`/compact` or automatic) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The auto-compact threshold defaults to 85% and is configurable via `locode config set autoCompactThreshold` or `LOCODE_AUTO_COMPACT_THRESHOLD`.
|
||||
- Plugin support (`locode plugin add`) now covers every part of a plugin: MCP servers, slash commands, agents, hooks, and skills. A plugin's `allowed-tools` restriction on a command isn't enforced (the expanded prompt just runs as a normal turn with the full toolset). Duplicate MCP server names across sources are now detected and surfaced in `/mcp` — project-level wins over user-level wins over plugin-level. Duplicate slash-command and skill names are also surfaced in `/plugins` and `/skills`.
|
||||
- Plugin support (`locode plugin add`) now covers every part of a plugin: MCP servers, slash commands, agents, hooks, and skills. A slash command's `allowed-tools` frontmatter restricts that one invocation's toolset (same tool-name translation as an agent's `tools:` — see `/plugins`); a skill invoked directly via `/<skill-name>` isn't restricted this way, since skills have no `allowed-tools` field of their own. Duplicate MCP server names across sources are now detected and surfaced in `/mcp` — project-level wins over user-level wins over plugin-level. Duplicate slash-command and skill names are also surfaced in `/plugins` and `/skills`.
|
||||
- Skills are exposed as one shared `skill` tool rather than one tool per skill — if two plugins install a skill with the same name, the first plugin in load order wins and the collision is shown in `/skills`. Skills can bundle sibling `references/*.md` files that are included when the skill is invoked.
|
||||
- Hooks cover the most useful subset of Claude Code's lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. `CwdChanged` is declared in the config format but not yet fired anywhere — a cwd never changes mid-session in locode today, so configuring it is a no-op for now. `command` hooks and `http` hooks are supported; `prompt` hooks are declared in the config format but not yet executed. Command hooks can opt into structured JSON output via `outputSchema: "json"`. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`), so it doesn't always have a real session id to report. All hooks for an event run in parallel with no defined ordering, and every configured hook always runs — there's no way to disable one without editing the file it came from.
|
||||
- Hooks cover the most useful subset of Claude Code's lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, `SubagentStop`, `CwdChanged`, `FileChanged`, `ConfigChange`, `Stop`, `SessionEnd`. `CwdChanged` is declared in the config format but not yet fired anywhere — a cwd never changes mid-session in locode today, so configuring it is a no-op for now. All three hook types are supported: `command` and `http` run an external process/request, and `prompt` just injects its static `message` as additional context (the same way a command/http hook's stdout does) — it has no process to fail, so it can't block an event the way a command hook's exit code 2 can. Command/http hooks can opt into structured JSON output via `outputSchema: "json"`. `PreToolUse`, `UserPromptSubmit`, and `PermissionRequest` can block; the rest are fire-and-forget. `SessionEnd` fires on every exit path (including Ctrl+C and external `SIGTERM`/`SIGHUP`), so it doesn't always have a real session id to report. All hooks for an event run in parallel with no defined ordering, and every configured hook always runs — there's no way to disable one without editing the file it came from.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# locode 설계 메모
|
||||
|
||||
## 프로젝트 개요
|
||||
**locode** — Claude Code의 설계 철학을 가져와 구현한 에이전트 코딩 CLI. TypeScript + Ink(React-for-CLI) 기반. 백엔드는 OpenAI 호환 `/v1/chat/completions` 엔드포인트 사용 (Ollama, LM Studio, 클라우드 API 모두 지원).
|
||||
|
||||
- 핵심 철학: "신뢰할 수 없고 느리고 비전/툴콜 지원이 불확실한 모델"이라는 현실에 맞춰 모든 가정을 비관적으로 재단. 로컬/클라우드 자동 감지로 프롬프트 분기.
|
||||
- Claude Code 플러그인 포맷을 직접 소비하는 하위호환 브리지 (`.claude-plugin/plugin.json`, commands/agents/skills/hooks/MCP)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 결정 이유
|
||||
|
||||
### Normal Screen + `<Static>` (alternate screen 폐지)
|
||||
이전에는 alternate screen buffer + 인앱 가상 스크롤 + 마우스 트래킹을 직접 구현했으나 완전히 폐지. 이유:
|
||||
- **코드 복잡도**: 마우스 SGR-1006 파싱, 선택 영역, 스크롤 상태 관리가 App.tsx의 절반을 차지
|
||||
- **터미널 호환성**: alternate screen은 SSH, tmux, Windows Terminal 등에서 파편화 심함
|
||||
- **버그 발생률**: 마우스 크래시, 선택 텍스트 깨짐 등 이슈가 끝없이 발생
|
||||
- **현재 방식**: Ink `<Static>`으로 완료된 히스토리를 한 번만 렌더링 → 터미널 스크롤백의 영구 부분. 재렌더링 없음. 마우스는 터미널 네이티브에 의존.
|
||||
|
||||
### 로컬/클라우드 모델 감지: `isSmallLocalModel()`
|
||||
- **이전**: `isLocalBackendURL(baseURL)` — URL이 localhost면 무조건 "로컬 모델"
|
||||
- **문제**: Ollama가 클라우드 라우팅 모델(`glm-5.2:cloud`, `qwen3.5:397b-cloud`)도 같은 localhost에서 서비스함. 이 모델들은 컨텍스트 128K~1M이고 툴콜도 안정적인데 로컬용 보수적 프롬프트가 적용됨.
|
||||
- **해결**: `isSmallLocalModel(baseURL, model)` = `isLocalBackendURL(baseURL) && !isCloudRoutedModelName(model)` — 모델명의 `:cloud`/`:-cloud` 태그로 구분.
|
||||
- **기본값**: `isLocal`의 기본값을 `true`에서 `false`(클라우드)로 변경. 명시적 지정이 없으면 보수적이 아닌 기본 프롬프트 사용.
|
||||
|
||||
### 컨텍스트 윈도우 기본값 분리
|
||||
- **이전**: `DEFAULT_CONTEXT_WINDOW = 8192` (단일, 로컬 모델 기준)
|
||||
- **현재**: `DEFAULT_CONTEXT_WINDOW_LOCAL = 8192`, `DEFAULT_CONTEXT_WINDOW_CLOUD = 131072` — 클라우드/Ollama 클라우드 라우팅 모델은 128K~1M 컨텍스트를 가지므로 8192는 과도하게 보수적.
|
||||
|
||||
### 번인레이트(🔥) 계산
|
||||
- **이전**: `outputTokens / 세션 경과 시간` — 사용자가 방치하면 번인레이트가 0에 수렴해서 의미 없음
|
||||
- **현재**: `outputTokens / modelTimeMs` — 실제 모델 응답 시간으로 계산. "이 모델이 얼마나 빠르게 토큰을 뿜는가"를 정확히 반영.
|
||||
|
||||
### 파일 인코딩: CRLF/LF 혼재
|
||||
- Windows 환경에서는 CRLF, Unix에서는 LF가 섞여 있음. `edit_file`/`multi_edit`은 매칭 전 LF로 정규화하고, 쓰기 전 원래 EOL을 복원. 이것 없이는 Windows에서 거의 모든 edit_file이 실패함.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 파일 맵
|
||||
- `src/ui/ink/index.tsx` — 진입점. alternate screen 없이 Ink render. cleanup 시 flush + 종료.
|
||||
- `src/ui/ink/App.tsx` — 메인 UI 컴포넌트. `<Static>` + 라이브 영역. 상태: starting→connecting→loading-models→model-select/session-select→input.
|
||||
- `src/ui/ink/ChatInput.tsx` — 커스텀 multiline 입력. Shift+Enter 줄바꿈, bracket paste, @멘션 fuzzy picker, IME 커서.
|
||||
- `src/agent/loop.ts` — 메인 에이전트 루프. 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치/반복 루프 감지.
|
||||
- `src/agent/session.ts` — Session 객체, 통계, 상태, mutation gate.
|
||||
- `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (`isSmallLocalModel` 기반 로컬/클라우드 분기).
|
||||
- `src/config/defaults.ts` — 모든 기본값. `isSmallLocalModel()`, `isCloudRoutedModelName()`, `DEFAULT_CONTEXT_WINDOW_LOCAL/CLOUD` 등.
|
||||
- `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, partialJson 복구, resolve (Ollama 빈키 복구 포함).
|
||||
|
||||
---
|
||||
|
||||
## 설정 기본값
|
||||
|
||||
| 설정 | 기본값 | 비고 |
|
||||
|---|---|---|
|
||||
| `DEFAULT_CONTEXT_WINDOW_LOCAL` | 8192 | 작은 로컬 모델 폴백 |
|
||||
| `DEFAULT_CONTEXT_WINDOW_CLOUD` | 131072 | 클라우드/클라우드 라우팅 폴백 |
|
||||
| `DEFAULT_MAX_ITERATIONS` | **300** | 50→100→300 상향 |
|
||||
| `DEFAULT_MAX_OUTPUT_TOKENS` | **131072** | 128K. GLM 등 1M 컨텍스트 모델 대응 |
|
||||
| `DEFAULT_MAX_RETRIES` | 0 | SDK 지수 백오프 |
|
||||
| `DEFAULT_AUTO_COMPACT_THRESHOLD` | 0.85 | |
|
||||
| `DEFAULT_REQUEST_TIMEOUT_MS` | 180,000 | 3분 |
|
||||
| `DEFAULT_SUBAGENT_TIMEOUT_MS` | 600,000 | 10분 |
|
||||
| `MAX_EMPTY_RESPONSE_RETRIES` | **3** | 1→3 상향 |
|
||||
| `MAX_SUBAGENT_DEPTH` | 1 | 서브에이전트 중첩 금지 |
|
||||
| `MAX_PRESERVED_TAIL_MESSAGES` | 8 | 컴팩션 시 보존 |
|
||||
| `MAX_PRESERVED_TAIL_FRACTION` | 0.3 | 컴팩션 시 보존 비율 |
|
||||
| `MAX_RETAINED_IMAGES` | 2 | 히스토리 이미지 보존 |
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅 힌트
|
||||
- **"자꾸 에러"**: 주요 원인은 max_tokens 잘림 → malformed 툴콜. 동적 max_tokens + CRLF 제어문자 이스케이프로 해결됨.
|
||||
- **CRLF edit_file 매칭 버그**: LF 정규화 공간에서 매칭, 쓰기 전 원래 EOL 복원으로 해결됨.
|
||||
- **"Paused after N steps"**: `locode config set maxIterations <number>` (기본 300)
|
||||
- **클라우드 모델 빈 응답**: `MAX_EMPTY_RESPONSE_RETRIES=3`으로 재시도
|
||||
- **로컬/클라우드 프롬프트 분기**: `isSmallLocalModel(baseURL, model)` — Ollama 클라우드 라우팅 모델(`:cloud` 태그)은 localhost여도 클라우드 프롬프트 사용
|
||||
- **반복 루프 감지**: `detectRepetitionLoop()` — 스트리밍 텍스트에서 짧은 반복 패턴 감지 시 중단
|
||||
- **번인레이트(🔥)**: `outputTokens ÷ modelTimeMs` 기준 (세션 경과 시간이 아닌 실제 모델 응답 시간)
|
||||
- **IPv6 localhost**: `isLocalBackendURL()`은 `[::1]` 형식(WHATWG URL 직렬화)도 인식
|
||||
|
||||
---
|
||||
|
||||
## 의존성 제약
|
||||
- **marked는 15에 고정**. `marked-terminal@7.3.0`의 peer가 `marked >=1 <16`이고 marked-terminal 업데이트가 없음. marked 16+로 올리려면 marked-terminal을 교체하거나 peer를 강제해야 함.
|
||||
- **typescript는 7.x (네이티브 컴파일러 포팅)**. 프로젝트는 `tsc` CLI로 타입체크만 하고 programmatic API를 안 씀 → 네이티브 포트로 안전하게 이전. 빌드는 tsup/esbuild라 tsc와 무관. 플랫폼별 `@typescript/typescript-*` 바이너리가 optional dep으로 붙음.
|
||||
- **wrap-ansi는 10.x (ink와 동일)**. `renderMarkdown`이 히스토리 출력을 터미널 폭으로 하드랩할 때 사용 — ink 내부 래핑과 같은 string-width v8을 공유해야 폭 계산이 어긋나지 않음. v10은 타입 내장(앰비언트 선언 불필요).
|
||||
- `allowScripts`에 트리에 실제로 존재하는 esbuild 버전을 모두 나열해야 함 (현재 `0.27.2` = tsup/vite, `0.28.1` = tsx). 빠지면 postinstall 경고.
|
||||
|
||||
## TODO
|
||||
- LSP 실서버 통합 테스트 (실제 tsserver/pyright 띄워서 검증)
|
||||
- task store 영속화 (세션에 task 저장)
|
||||
Generated
+1408
-507
File diff suppressed because it is too large
Load Diff
+21
-14
@@ -10,7 +10,7 @@
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
@@ -20,32 +20,39 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"commander": "^13.0.0",
|
||||
"commander": "^15.0.0",
|
||||
"diff": "^9.0.0",
|
||||
"env-paths": "^4.0.0",
|
||||
"execa": "^9.6.1",
|
||||
"execa": "^10.0.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ink": "^7.1.0",
|
||||
"ink": "^7.1.1",
|
||||
"ink-select-input": "^6.2.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-terminal": "^7.3.0",
|
||||
"openai": "^6.45.0",
|
||||
"react": "^19.2.7",
|
||||
"string-width": "^8.2.1",
|
||||
"openai": "^7.13.0",
|
||||
"react": "^19.3.0",
|
||||
"string-width": "^8.2.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"zod": "^4.4.3"
|
||||
"vscode-languageserver-protocol": "^3.18.3",
|
||||
"vscode-uri": "^3.2.0",
|
||||
"wrap-ansi": "^10.0.1",
|
||||
"zod": "^4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/marked-terminal": "^6.1.1",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"tsup": "^8.3.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.27.2": true,
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -3,12 +3,19 @@ import type { TodoItem } from "../tools/types.js";
|
||||
export type AgentEvent =
|
||||
| { type: "text_delta"; delta: string }
|
||||
| { type: "text_done"; fullText: string }
|
||||
| { type: "thinking_delta"; delta: string }
|
||||
| { type: "thinking_done"; fullThinking: string }
|
||||
/** Throw away any text streamed so far this turn without committing it as an assistant message —
|
||||
* emitted when a partially-streamed native tool-call turn turns out to have malformed args and is
|
||||
* retried non-streaming, so the UI doesn't carry the stale partial into the retry's output. */
|
||||
| { type: "stream_discard" }
|
||||
| { type: "tool_call"; label: string }
|
||||
| { type: "tool_result"; summary: string; isError: boolean }
|
||||
/** `name`/`args` are only populated for an actually-resolved tool call (not the "unknown tool"
|
||||
* error path) — the file panel's Activity tab (App.tsx) uses them to track which files a
|
||||
* read_file/write_file/edit_file call touched, without having to re-parse the display `label`. */
|
||||
| { type: "tool_call"; label: string; name?: string; args?: unknown }
|
||||
/** `name`/`result` mirror `tool_call`'s — only populated when a tool actually ran (not a
|
||||
* hook-blocked/denied/unknown-tool result), for the same file-panel tracking purpose. */
|
||||
| { type: "tool_result"; summary: string; isError: boolean; name?: string; result?: unknown }
|
||||
/** A sub-agent's tool call or result, forwarded to the parent so its work is visible while it
|
||||
* runs headless. Routed to the dedicated sub-agent panel below the input (not the main
|
||||
* scrollback) — see App.tsx. */
|
||||
|
||||
+473
-1
@@ -1,13 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { MaxIterationsError, runTurn, shouldAutoCompact } from "./loop.js";
|
||||
import { AgentError, MaxIterationsError, compactSession, detectRepetitionLoop, runTurn, shouldAutoCompact } from "./loop.js";
|
||||
import { agentTool } from "../tools/agentTool.js";
|
||||
import { createSession } from "./session.js";
|
||||
import { buildToolSet } from "../tools/toolset.js";
|
||||
import { DEFAULT_MAX_OUTPUT_TOKENS } from "../config/defaults.js";
|
||||
import type { ConfirmFn } from "../permissions/types.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
import type { Session } from "./session.js";
|
||||
|
||||
describe("detectRepetitionLoop", () => {
|
||||
it("returns false for short text regardless of content", () => {
|
||||
expect(detectRepetitionLoop("error error error error error error")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a short phrase repeating many times in a row", () => {
|
||||
const text = "Here is the analysis: " + "error error error ".repeat(80);
|
||||
expect(detectRepetitionLoop(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a longer repeated unit (e.g. a repeated JSON-like fragment)", () => {
|
||||
const unit = '{"status":"retry","reason":"pending"} ';
|
||||
const text = "Starting work.\n" + unit.repeat(40);
|
||||
expect(detectRepetitionLoop(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag normal varied prose", () => {
|
||||
const paragraphs = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `Paragraph ${i}: this covers a different point each time, with enough unique wording to avoid any short repeating pattern in the tail.`,
|
||||
);
|
||||
expect(detectRepetitionLoop(paragraphs.join("\n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag a legitimately repetitive but non-degenerate bullet list", () => {
|
||||
const items = Array.from({ length: 30 }, (_, i) => `- item ${i}: some unique detail about entry number ${i}`);
|
||||
expect(detectRepetitionLoop(items.join("\n"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoCompact", () => {
|
||||
it("triggers at the session's configured threshold", () => {
|
||||
const session = {
|
||||
@@ -22,6 +53,141 @@ describe("shouldAutoCompact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn / max_tokens", () => {
|
||||
it("caps max_tokens independent of a large contextWindow", async () => {
|
||||
// Regression: some backends advertise a huge context window but cap a single response's
|
||||
// max_tokens far below it (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 131,072-token
|
||||
// max output). resolveMaxTokens used to request up to the whole remaining window, which such
|
||||
// backends reject outright — worse the larger (or user-raised) contextWindow got.
|
||||
let capturedMaxTokens: number | undefined;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (params: any) => {
|
||||
capturedMaxTokens = params.max_tokens;
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] } };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.contextWindow = 1_000_000;
|
||||
session.lastContextTokens = 500;
|
||||
|
||||
await runTurn(session, "hi", () => {});
|
||||
|
||||
expect(capturedMaxTokens).toBeLessThanOrEqual(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn / repetition detection", () => {
|
||||
it("aborts a streamed response that degenerates into a repetition loop", async () => {
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (_params: any, options: any) => {
|
||||
const signal: AbortSignal | undefined = options?.signal;
|
||||
let count = 0;
|
||||
// A real backend stuck in a loop would keep streaming the same short phrase forever;
|
||||
// this fake mirrors that but stops once aborted (or hits a safety cap, so a detection
|
||||
// regression fails the test instead of hanging it).
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (signal?.aborted || count >= 500) return { done: true, value: undefined };
|
||||
count++;
|
||||
return { done: false, value: { choices: [{ delta: { content: "loop " }, finish_reason: null }] } };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
|
||||
await expect(runTurn(session, "hi", () => {})).rejects.toThrow(AgentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn / empty response retry", () => {
|
||||
it("retries once on a bare empty stop, then succeeds on the next call", async () => {
|
||||
// Small local models sometimes emit a stream that ends with finish_reason "stop" but no
|
||||
// content and no tool calls. Without a retry this aborted the whole turn as a hard error.
|
||||
// Here the first create() returns an empty stop; the second returns real text, so the turn
|
||||
// should recover and return that text (with a nudge message inserted in between).
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (_params: any) => {
|
||||
call++;
|
||||
const chunk = call === 1
|
||||
? { choices: [{ delta: {}, finish_reason: "stop" }] } // empty stop, no content
|
||||
: { choices: [{ delta: { content: "all done" }, finish_reason: "stop" }] };
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
const result = await runTurn(session, "hi", () => {});
|
||||
expect(result).toBe("all done");
|
||||
expect(call).toBe(2); // one empty + one successful
|
||||
// A nudge user message must have been inserted after the empty response.
|
||||
expect(session.messages.some((m) => m.role === "user" && typeof m.content === "string" && m.content.includes("empty response"))).toBe(true);
|
||||
});
|
||||
|
||||
it("throws after the retry budget is exhausted on repeated empty responses", async () => {
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (_params: any) => {
|
||||
call++;
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: { choices: [{ delta: {}, finish_reason: "stop" }] } };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
await expect(runTurn(session, "hi", () => {})).rejects.toThrow("Empty response from model.");
|
||||
// One initial empty + three retries = 4 calls total.
|
||||
expect(call).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTurn / max iterations", () => {
|
||||
it("throws MaxIterationsError (not a generic error) when a model keeps calling tools forever", async () => {
|
||||
// A no-op tool the fake model calls on every single turn, forever — simulates a model that
|
||||
@@ -284,6 +450,66 @@ describe("runTurn / max iterations", () => {
|
||||
expect(capturedSignal).toBe(ac.signal);
|
||||
});
|
||||
|
||||
it("stops mid-stream when the caller's signal aborts, without misreporting it as an idle timeout", async () => {
|
||||
// Regression for Escape-to-interrupt (App.tsx now threads a per-turn AbortController's signal
|
||||
// into the top-level runTurn call the same way sub-agents already did). This covers the other
|
||||
// half of that feature from the previous test: interrupting while the model is still streaming
|
||||
// plain prose, with no tool call or confirm() involved at all.
|
||||
let sawSecondNext = false;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async (_params: any, options: any) => {
|
||||
const signal: AbortSignal | undefined = options?.signal;
|
||||
let calls = 0;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return { done: false, value: { choices: [{ delta: { content: "Hello" }, index: 0 }] } };
|
||||
}
|
||||
sawSecondNext = true;
|
||||
// A real aborted fetch stream rejects rather than yielding forever — mimic that,
|
||||
// gated on the actual signal so this test depends on abort reaching the request.
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const err = new Error("The operation was aborted.");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
const ac = new AbortController();
|
||||
|
||||
const turnPromise = runTurn(session, "say something long", () => {}, undefined, ac.signal);
|
||||
// Let the first content chunk land (setting the streaming text in motion) before interrupting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
ac.abort();
|
||||
|
||||
await expect(turnPromise).rejects.toThrow();
|
||||
expect(sawSecondNext).toBe(true);
|
||||
// The whole point of threading a real external signal (vs. relying on the idle guard alone) is
|
||||
// that App.tsx can tell "the user stopped this" apart from "the backend actually hung" — which
|
||||
// it does by checking its own AbortController's `.aborted` flag, not by string-matching the
|
||||
// error. But the idle-timeout message specifically must not leak out here, since it would be an
|
||||
// actively misleading claim (the backend didn't go silent; the user interrupted it) if App.tsx's
|
||||
// fallback error-message path (or any future caller) ever surfaced it directly.
|
||||
await expect(turnPromise).rejects.not.toThrow(/Backend stopped responding mid-stream/);
|
||||
});
|
||||
|
||||
it("prunes older images from history, keeping only the most recent few at full resolution", async () => {
|
||||
// Unlike text tool results, an image's base64 payload has no per-call cap and (without pruning)
|
||||
// gets resent in full on every subsequent request for the rest of the session — a handful of
|
||||
@@ -436,6 +662,163 @@ describe("runTurn / max iterations", () => {
|
||||
expect(content).not.toContain("send another message to continue");
|
||||
});
|
||||
|
||||
it("runs multiple read-only tools in parallel, not sequentially", async () => {
|
||||
// When a model returns multiple tool calls that are all read-only, runToolBatch
|
||||
// should execute them concurrently (Promise.all), not one-by-one. This test
|
||||
// verifies that by checking the actual wall-clock time: 3 tools each sleeping
|
||||
// 200ms should complete in well under 600ms if parallel, but ~600ms if sequential.
|
||||
const SLEEP_MS = 200;
|
||||
const readTool: ToolDef = {
|
||||
name: "read_file",
|
||||
description: "reads a file",
|
||||
schema: z.object({ path: z.string() }),
|
||||
mutating: false,
|
||||
handler: async () => {
|
||||
await new Promise((r) => setTimeout(r, SLEEP_MS));
|
||||
return { content: "file contents" };
|
||||
},
|
||||
};
|
||||
const toolset = buildToolSet([readTool]);
|
||||
|
||||
// Model returns 3 parallel read_file calls in one response
|
||||
function threeReadCallsChunk() {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_0", function: { name: "read_file", arguments: '{"path":"a.ts"}' } },
|
||||
{ index: 1, id: "call_1", function: { name: "read_file", arguments: '{"path":"b.ts"}' } },
|
||||
{ index: 2, id: "call_2", function: { name: "read_file", arguments: '{"path":"c.ts"}' } },
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function finalTextChunk() {
|
||||
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
|
||||
}
|
||||
|
||||
let streamingCallCount = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
streamingCallCount++;
|
||||
const chunk = streamingCallCount === 1 ? threeReadCallsChunk() : finalTextChunk();
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [readTool]);
|
||||
session.toolset = toolset;
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runTurn(session, "read three files", () => {});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe("done");
|
||||
// 3 × 200ms sequentially = 600ms+. In parallel = ~200ms + overhead.
|
||||
// Allow generous slack but still well under the sequential floor.
|
||||
expect(elapsed).toBeLessThan(SLEEP_MS * 2.5);
|
||||
});
|
||||
|
||||
it("runs mixed read+write tool calls sequentially even when model sends them together", async () => {
|
||||
// When a model returns multiple tool calls that include at least one mutating tool,
|
||||
// the entire batch should run sequentially, not in parallel.
|
||||
const SLEEP_MS = 150;
|
||||
const readTool: ToolDef = {
|
||||
name: "read_file",
|
||||
description: "reads a file",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async () => {
|
||||
await new Promise((r) => setTimeout(r, SLEEP_MS));
|
||||
return { content: "file contents" };
|
||||
},
|
||||
};
|
||||
const editTool: ToolDef = {
|
||||
name: "edit_file",
|
||||
description: "edits a file",
|
||||
schema: z.object({}),
|
||||
mutating: true,
|
||||
handler: async () => {
|
||||
await new Promise((r) => setTimeout(r, SLEEP_MS));
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
const toolset = buildToolSet([readTool, editTool]);
|
||||
|
||||
// Model sends 1 read + 1 edit together
|
||||
function mixedCallsChunk() {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_0", function: { name: "read_file", arguments: "{}" } },
|
||||
{ index: 1, id: "call_1", function: { name: "edit_file", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function finalTextChunk() {
|
||||
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
|
||||
}
|
||||
|
||||
let streamingCallCount = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
streamingCallCount++;
|
||||
const chunk = streamingCallCount === 1 ? mixedCallsChunk() : finalTextChunk();
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const confirm = vi.fn(async () => "once" as const);
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), confirm, "native", [readTool, editTool]);
|
||||
session.toolset = toolset;
|
||||
// Auto-approve edit so we don't need user interaction
|
||||
session.permissions.allowForSession("edit_file");
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runTurn(session, "read and edit", () => {});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe("done");
|
||||
// Sequential: 2 × 150ms = 300ms minimum. Allow generous slack.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(SLEEP_MS * 1.5);
|
||||
});
|
||||
|
||||
it("blocks mutating tools outright in plan mode, without ever prompting for confirmation", async () => {
|
||||
const mutatingTool: ToolDef = {
|
||||
name: "edit_file",
|
||||
@@ -497,3 +880,92 @@ describe("runTurn / max iterations", () => {
|
||||
expect(String((toolResultMessage as any).content)).toContain("plan mode is active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactSession — partial-history preservation", () => {
|
||||
function fakeClientReturning(summary: string) {
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => ({
|
||||
choices: [{ message: { role: "assistant", content: summary } }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 20 },
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("preserves recent tail messages verbatim and summarizes the older prefix", async () => {
|
||||
const client = fakeClientReturning("SUMMARY OF EARLIER WORK");
|
||||
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.contextWindow = 4096;
|
||||
|
||||
// Build a history longer than the preserved tail: system + several user/assistant turns.
|
||||
// Index 0 is the system prompt that createSession already added.
|
||||
for (let i = 0; i < 12; i++) {
|
||||
session.messages.push({ role: "user", content: `user message ${i}` } as any);
|
||||
session.messages.push({ role: "assistant", content: `assistant reply ${i}` } as any);
|
||||
}
|
||||
|
||||
const beforeLen = session.messages.length;
|
||||
const summary = await compactSession(session);
|
||||
|
||||
expect(summary).toBe("SUMMARY OF EARLIER WORK");
|
||||
// The recap must be present.
|
||||
const recap = session.messages.find((m) => m.role === "assistant" && typeof m.content === "string" && (m.content as string).includes("[Earlier conversation compacted"));
|
||||
expect(recap).toBeDefined();
|
||||
// The most recent assistant reply must survive compaction (it is in the tail).
|
||||
expect(session.messages.some((m) => m.role === "assistant" && (m.content as string) === "assistant reply 11")).toBe(true);
|
||||
// The oldest user message (well before the tail) must NOT survive verbatim — it was summarized.
|
||||
expect(session.messages.some((m) => m.role === "user" && (m.content as string) === "user message 0")).toBe(false);
|
||||
// History shrunk but is not empty.
|
||||
expect(session.messages.length).toBeLessThan(beforeLen);
|
||||
expect(session.messages.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("keeps an assistant tool_calls message grouped with its tool result messages in the tail", async () => {
|
||||
const client = fakeClientReturning("SUMMARY");
|
||||
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.contextWindow = 4096;
|
||||
|
||||
// Add enough history that the boundary lands inside the tool-call group.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
session.messages.push({ role: "user", content: `u${i}` } as any);
|
||||
session.messages.push({ role: "assistant", content: `a${i}` } as any);
|
||||
}
|
||||
// Now the most recent turns: an assistant tool_calls message + its tool results.
|
||||
session.messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_0", type: "function", function: { name: "read_file", arguments: "{}" } }],
|
||||
} as any);
|
||||
session.messages.push({ role: "tool", tool_call_id: "call_0", content: "file contents" } as any);
|
||||
|
||||
await compactSession(session);
|
||||
|
||||
// The tool_calls assistant message and its tool result must both be present in the tail.
|
||||
const hasCall = session.messages.some(
|
||||
(m) => m.role === "assistant" && Array.isArray((m as any).tool_calls) && (m as any).tool_calls.some((c: any) => c.id === "call_0"),
|
||||
);
|
||||
const hasResult = session.messages.some((m) => m.role === "tool" && (m as any).tool_call_id === "call_0");
|
||||
// Both or neither — never one without the other (that would be a malformed history).
|
||||
expect(hasCall).toBe(hasResult);
|
||||
expect(hasCall).toBe(true);
|
||||
});
|
||||
|
||||
it("does not call the model when there is no older prefix to summarize", async () => {
|
||||
const client = fakeClientReturning("SHOULD NOT BE USED");
|
||||
const session = createSession(client, "test-model", process.cwd(), async () => "once", "native", []);
|
||||
session.contextWindow = 4096;
|
||||
// Only system + a couple of messages: the tail covers everything, so no summary request.
|
||||
session.messages.push({ role: "user", content: "hi" } as any);
|
||||
session.messages.push({ role: "assistant", content: "hello" } as any);
|
||||
|
||||
const summary = await compactSession(session);
|
||||
|
||||
expect(summary).toBe("");
|
||||
expect((client.chat.completions.create as any).mock.calls.length).toBe(0);
|
||||
// The recent messages are still there verbatim.
|
||||
expect(session.messages.some((m) => m.role === "assistant" && (m.content as string) === "hello")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+500
-116
@@ -13,10 +13,13 @@ import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
|
||||
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
||||
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
|
||||
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
|
||||
import { repairPartialJson } from "../toolcalling/partialJson.js";
|
||||
import { notifyFileChanged } from "../codeintel/lspManager.js";
|
||||
import { TaskStore } from "../tools/task.js";
|
||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { runHooksForEvent } from "../hooks/runner.js";
|
||||
import { resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "../config/config.js";
|
||||
import { resolveMaxOutputTokens, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "../config/config.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
import type { Session } from "./session.js";
|
||||
|
||||
@@ -26,12 +29,37 @@ function emitHookWarnings(warnings: string[], emit: AgentEventHandler): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps runHooksForEvent for the events that fire on every tool call (PreToolUse, PostToolUse,
|
||||
* PermissionRequest) or every sub-agent (SubagentStart) — same "hooks must not derail things"
|
||||
* reasoning as fireStopHook/fireSessionStartHook/fireUserPromptSubmitHook, but those wrap a single
|
||||
* dedicated call site each; this one is shared since these fire from inline code in gateAndRun and
|
||||
* runSubAgentTurn rather than their own named functions. A broken hook (bad command, malformed
|
||||
* hooks.json) must not block a tool call or sub-agent it never got to properly evaluate. */
|
||||
async function safeRunHooksForEvent(
|
||||
...args: Parameters<typeof runHooksForEvent>
|
||||
): Promise<Awaited<ReturnType<typeof runHooksForEvent>>> {
|
||||
try {
|
||||
return await runHooksForEvent(...args);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${args[0]}] hook error (non-fatal):`, err);
|
||||
return { blocked: false, warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop fires once the assistant has produced its final answer for a turn — informational only
|
||||
* (e.g. a desktop notification or logging hook); unlike PreToolUse it can't block, since there's
|
||||
* nothing left in this turn to prevent. */
|
||||
async function fireStopHook(session: Session, emit: AgentEventHandler, finalText: string): Promise<void> {
|
||||
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
|
||||
emitHookWarnings(result.warnings, emit);
|
||||
try {
|
||||
const result = await runHooksForEvent("Stop", { sessionId: session.id, cwd: session.cwd }, { final_text: finalText });
|
||||
emitHookWarnings(result.warnings, emit);
|
||||
} catch (err) {
|
||||
// Stop hooks are informational only (e.g. notifications, logging). A failing hook must never
|
||||
// discard the turn's final answer — log the error and continue.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireStopHook] hook error (non-fatal):", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs SessionStart hooks right after a session is created — any hook that exits 0 with stdout
|
||||
@@ -47,12 +75,19 @@ function foldHookContext(systemContent: string, result: Awaited<ReturnType<typeo
|
||||
}
|
||||
|
||||
export async function fireSessionStartHook(session: Session): Promise<string[]> {
|
||||
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
|
||||
if (result.additionalContext || result.jsonContext?.length) {
|
||||
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
|
||||
systemMessage.content = foldHookContext(systemMessage.content, result);
|
||||
try {
|
||||
const result = await runHooksForEvent("SessionStart", { sessionId: session.id, cwd: session.cwd }, {});
|
||||
if (result.additionalContext || result.jsonContext?.length) {
|
||||
const systemMessage = session.messages[0] as ChatCompletionMessageParam & { content: string };
|
||||
systemMessage.content = foldHookContext(systemMessage.content, result);
|
||||
}
|
||||
return result.warnings;
|
||||
} catch (err) {
|
||||
// A failing SessionStart hook must not prevent the session from starting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireSessionStartHook] hook error (non-fatal):", err);
|
||||
return [];
|
||||
}
|
||||
return result.warnings;
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitResult {
|
||||
@@ -66,14 +101,21 @@ export interface UserPromptSubmitResult {
|
||||
/** Runs UserPromptSubmit hooks for a message the user is about to send — can block it outright
|
||||
* (e.g. a policy check) or inject extra context that gets appended alongside it. */
|
||||
export async function fireUserPromptSubmitHook(session: Session, prompt: string): Promise<UserPromptSubmitResult> {
|
||||
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
|
||||
return {
|
||||
blocked: result.blocked,
|
||||
reason: result.reason,
|
||||
additionalContext: result.additionalContext,
|
||||
jsonContext: result.jsonContext,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
try {
|
||||
const result = await runHooksForEvent("UserPromptSubmit", { sessionId: session.id, cwd: session.cwd }, { prompt });
|
||||
return {
|
||||
blocked: result.blocked,
|
||||
reason: result.reason,
|
||||
additionalContext: result.additionalContext,
|
||||
jsonContext: result.jsonContext,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
// A failing UserPromptSubmit hook must not block the user from sending their message.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[fireUserPromptSubmitHook] hook error (non-fatal):", err);
|
||||
return { blocked: false, warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentError extends Error {}
|
||||
@@ -120,7 +162,42 @@ export function createIdleAbort(
|
||||
};
|
||||
}
|
||||
|
||||
/** Detects a degenerate repetition loop: a short substring repeating many times in a row at the
|
||||
* tail of the streamed text. Known failure mode for local/quantized models, which otherwise only
|
||||
* stop at max_tokens or an idle timeout — with maxOutputTokens raised for backends whose real
|
||||
* ceiling is far larger, an undetected loop could run for a very long time before either kicks in.
|
||||
* Only checks the tail (loops are contiguous, so recent text is sufficient and cheap to scan) and
|
||||
* requires several consecutive exact repeats, so legitimately repetitive-but-valid text (a bullet
|
||||
* list, a table, ASCII art) doesn't false-positive. */
|
||||
export function detectRepetitionLoop(text: string): boolean {
|
||||
const TAIL = 1000;
|
||||
const MIN_PERIOD = 4;
|
||||
const MAX_PERIOD = 200;
|
||||
const MIN_REPEATS = 6;
|
||||
if (text.length < TAIL) return false;
|
||||
const tail = text.slice(-TAIL);
|
||||
for (let period = MIN_PERIOD; period <= MAX_PERIOD; period++) {
|
||||
const repeats = Math.floor(tail.length / period);
|
||||
if (repeats < MIN_REPEATS) continue;
|
||||
const unit = tail.slice(tail.length - period);
|
||||
let matched = true;
|
||||
for (let r = 2; r <= repeats; r++) {
|
||||
const start = tail.length - r * period;
|
||||
if (tail.slice(start, start + period) !== unit) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const MAX_MALFORMED_RETRIES = 2;
|
||||
/** Local models (especially small quantized ones) occasionally emit a bare empty `stop`
|
||||
* chunk — no text, no tool calls. Rather than abort the whole turn as a hard error, retry
|
||||
* once with a short nudge so the model continues, mirroring the malformed-tool-call path. */
|
||||
const MAX_EMPTY_RESPONSE_RETRIES = 3;
|
||||
|
||||
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
|
||||
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
|
||||
@@ -155,63 +232,187 @@ export function shouldAutoCompact(session: Session): boolean {
|
||||
return contextUsageRatio(session) >= session.autoCompactThreshold;
|
||||
}
|
||||
|
||||
/** Computes a dynamic `max_tokens` for a generation request, instead of the old hardcoded 4096
|
||||
* that was far too small for local models — a full-file rewrite (common in fallback mode, where
|
||||
* precise edits are hard) can easily exceed 4096 tokens and get truncated mid-tool-call, turning
|
||||
* a valid call into malformed JSON the parser then rejects.
|
||||
*
|
||||
* Reserves `contextWindow − lastContextTokens` for the response, minus a small safety margin so
|
||||
* the prompt+response never overshoots the window (a local backend will typically OOM or error
|
||||
* on an oversized request rather than gracefully truncating). Clamped to [2048, contextWindow]
|
||||
* so a nearly-full context still gets a usable (if small) output budget, and a huge window does
|
||||
* not ask for more than the model could ever produce — but also capped at resolveMaxOutputTokens()
|
||||
* independent of contextWindow, since many backends cap a single response far below their total
|
||||
* context window (e.g. Ollama's glm-5.2:cloud: 1,000,000-token context, 8192-token max output).
|
||||
* Without that second cap, a large (or user-raised, see `contextWindow` config) window made
|
||||
* resolveMaxTokens request far more than such a backend allows, which it rejects outright as a
|
||||
* context/length error even on the very first turn — raising contextWindow to fix truncation made
|
||||
* this worse, not better. */
|
||||
function resolveMaxTokens(session: Session): number {
|
||||
const MARGIN = 512;
|
||||
const MIN = 2048;
|
||||
const available = session.contextWindow - session.lastContextTokens - MARGIN;
|
||||
const cap = Math.min(session.contextWindow, resolveMaxOutputTokens());
|
||||
return Math.min(cap, Math.max(MIN, Math.min(available, cap)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the conversation history with a model-generated summary of everything so far, to free
|
||||
* up context. Runs as a plain (non-tool-calling) request so the model just produces prose, not
|
||||
* more tool calls. The new history is just [system, a synthetic assistant "recap"] — framing the
|
||||
* summary as an assistant turn keeps proper role alternation with whatever real user message
|
||||
* follows next.
|
||||
* Maximum number of trailing messages to keep verbatim across a compaction. Sized so the
|
||||
* preserved tail is large enough to carry a couple of recent tool calls + results (the context
|
||||
* the model most needs to continue), but small enough that summarizing the older prefix actually
|
||||
* frees meaningful context. The tail is also capped by a fraction of the context window so a
|
||||
* handful of very large messages (e.g. big file reads) can not crowd out the summary.
|
||||
*/
|
||||
const MAX_PRESERVED_TAIL_MESSAGES = 8;
|
||||
const MAX_PRESERVED_TAIL_FRACTION = 0.3;
|
||||
|
||||
/**
|
||||
* Finds the index into `session.messages` at which the preserved tail should begin, so that the
|
||||
* tail keeps the most recent turns verbatim while the older prefix gets summarized. The boundary
|
||||
* is always placed on a safe edge — never between an assistant tool_calls message and its `tool`
|
||||
* result messages, since splitting that group would leave dangling tool calls (no results) or
|
||||
* orphaned results (no triggering call), which OpenAI-compatible backends reject as malformed.
|
||||
*
|
||||
* Returns at least 1 (never splits before the system prompt at index 0).
|
||||
*/
|
||||
function computeKeepBoundary(session: Session): number {
|
||||
const msgs = session.messages;
|
||||
if (msgs.length <= 1) return 1;
|
||||
|
||||
// Start from the end and walk backward, collecting messages into the tail until we hit the
|
||||
// message budget. Tool-result messages must stay grouped with the assistant tool_calls message
|
||||
// that precedes them, so when we reach one we keep scanning left until that call is included.
|
||||
let boundary = msgs.length;
|
||||
let kept = 0;
|
||||
const tailTokenBudget = Math.floor(session.contextWindow * MAX_PRESERVED_TAIL_FRACTION);
|
||||
let tailTokens = 0;
|
||||
|
||||
for (let i = msgs.length - 1; i >= 1; i--) {
|
||||
const msg = msgs[i]!;
|
||||
const isToolResult = msg.role === "tool" || isFallbackToolResult(msg);
|
||||
const isAssistantToolCall = msg.role === "assistant" && Array.isArray((msg as any).tool_calls) && (msg as any).tool_calls.length > 0;
|
||||
|
||||
// If we already started a tail and the next message left is a tool result whose triggering
|
||||
// assistant call is even further left, keep extending left to include that call — otherwise
|
||||
// the tail would start with orphaned tool results (no triggering call), which backends reject.
|
||||
if (isToolResult && boundary < msgs.length && boundary > i + 1) {
|
||||
tailTokens += estimateTokens([msg]);
|
||||
boundary = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kept >= MAX_PRESERVED_TAIL_MESSAGES || tailTokens >= tailTokenBudget) {
|
||||
break;
|
||||
}
|
||||
|
||||
tailTokens += estimateTokens([msg]);
|
||||
boundary = i;
|
||||
kept++;
|
||||
}
|
||||
|
||||
// Never return 0 — the system prompt at index 0 always belongs to the summarized prefix
|
||||
// (the summary request needs it as framing, and the post-compaction history rebuilds it).
|
||||
return Math.max(1, boundary);
|
||||
}
|
||||
|
||||
/** Detects a fallback-mode tool result: a user-role message whose content is a tool_result block. */
|
||||
function isFallbackToolResult(msg: ChatCompletionMessageParam): boolean {
|
||||
if (msg.role !== "user") return false;
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
if (typeof content !== "string") return false;
|
||||
return content.startsWith("```tool_result\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial-history compaction: summarizes the older prefix of the conversation and keeps the most
|
||||
* recent turns verbatim, to free context without discarding the exact tool calls + results the
|
||||
* model most needs to continue its task. Runs a plain (non-tool-calling) request for the summary.
|
||||
* The new history is [system, a synthetic assistant "recap", ...preserved tail] — framing the
|
||||
* summary as an assistant turn keeps proper role alternation with the preserved messages that
|
||||
* follow (which already begin with whatever role naturally came next in the original history).
|
||||
*/
|
||||
export async function compactSession(session: Session): Promise<string> {
|
||||
const requestMessages: ChatCompletionMessageParam[] = [
|
||||
...session.messages,
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " +
|
||||
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
|
||||
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing.",
|
||||
},
|
||||
];
|
||||
// Partial-history preservation: keep the most recent turns verbatim and only summarize the
|
||||
// older prefix. On a local backend a full compaction is expensive (one extra model request) and
|
||||
// the freshly-discarded turns are exactly the ones the model most needs to continue its task —
|
||||
// losing the last tool call + its result, for example, leaves it unable to reference what it
|
||||
// just did. So split the history at a safe boundary (never mid-tool-call) and keep the tail.
|
||||
const keepFromIndex = computeKeepBoundary(session);
|
||||
|
||||
const requestStart = Date.now();
|
||||
const compactGuard = createIdleAbort(resolveRequestTimeoutMs());
|
||||
let res;
|
||||
try {
|
||||
res = await session.client.chat.completions.create(
|
||||
// The prefix to summarize is everything before the preserved tail (always including the
|
||||
// system prompt at index 0, which the model needs as framing for the summary request).
|
||||
const prefix = session.messages.slice(0, keepFromIndex);
|
||||
const tail = session.messages.slice(keepFromIndex);
|
||||
|
||||
// If there's almost nothing old to summarize (e.g. a very short history or one where the tail
|
||||
// already covers most of it), don't waste a model request producing a near-empty summary —
|
||||
// just keep the tail verbatim. This also avoids a degenerate "[system, recap, single-message]"
|
||||
// result when compaction triggers early on a small history.
|
||||
const prefixHasContent = prefix.length > 1; // more than just the system prompt
|
||||
let summary: string | null = null;
|
||||
|
||||
if (prefixHasContent) {
|
||||
const requestMessages: ChatCompletionMessageParam[] = [
|
||||
...prefix,
|
||||
{
|
||||
model: session.model,
|
||||
messages: requestMessages,
|
||||
stream: false,
|
||||
max_tokens: 1024,
|
||||
role: "user",
|
||||
content:
|
||||
"Summarize the conversation up to this point, concisely but completely: the user's goals, key decisions " +
|
||||
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
|
||||
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing. " +
|
||||
"(The most recent turns are kept verbatim after this summary, so focus on the earlier history.)",
|
||||
},
|
||||
{ signal: compactGuard.signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (compactGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Compaction failed: backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s).`,
|
||||
];
|
||||
|
||||
const requestStart = Date.now();
|
||||
const compactGuard = createIdleAbort(resolveRequestTimeoutMs());
|
||||
let res;
|
||||
try {
|
||||
res = await session.client.chat.completions.create(
|
||||
{
|
||||
model: session.model,
|
||||
messages: requestMessages,
|
||||
stream: false,
|
||||
max_tokens: 1024,
|
||||
},
|
||||
{ signal: compactGuard.signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (compactGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Compaction failed: backend stopped responding (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s).`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
compactGuard.dispose();
|
||||
}
|
||||
session.stats.modelTimeMs += Date.now() - requestStart;
|
||||
recordUsage(session, res.usage);
|
||||
summary = res.choices[0]?.message?.content ?? null;
|
||||
if (!summary) {
|
||||
throw new AgentError("Compaction failed: the model returned no summary.");
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
compactGuard.dispose();
|
||||
}
|
||||
session.stats.modelTimeMs += Date.now() - requestStart;
|
||||
recordUsage(session, res.usage);
|
||||
const summary = res.choices[0]?.message?.content;
|
||||
if (!summary) {
|
||||
throw new AgentError("Compaction failed: the model returned no summary.");
|
||||
}
|
||||
|
||||
session.messages = [
|
||||
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions) },
|
||||
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
|
||||
];
|
||||
if (summary) {
|
||||
session.messages = [
|
||||
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
|
||||
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
|
||||
...tail,
|
||||
];
|
||||
} else {
|
||||
// Nothing to summarize: keep the tail as-is, but still rebuild the system prompt in case
|
||||
// the tail's first message was a stale system prompt we want to replace.
|
||||
session.messages = [
|
||||
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode, session.projectInstructions, session.isLocal) },
|
||||
...tail,
|
||||
];
|
||||
}
|
||||
// res.usage describes the old (now-discarded) prompt, not the new shorter history — estimate fresh.
|
||||
session.lastContextTokens = estimateTokens(session.messages);
|
||||
session.lastContextTokensIsEstimate = true;
|
||||
return summary;
|
||||
return summary ?? "";
|
||||
}
|
||||
|
||||
/** Accumulates streaming tool-call deltas into complete tool calls. */
|
||||
@@ -358,12 +559,13 @@ async function gateAndRun(
|
||||
): Promise<unknown> {
|
||||
session.stats.toolCalls++;
|
||||
if ("error" in resolved) {
|
||||
console.error(`[gateAndRun] RESOLVE ERROR: ${resolved.error}`);
|
||||
emit({ type: "tool_call", label: rawLabel });
|
||||
emit({ type: "tool_result", summary: resolved.error, isError: true });
|
||||
return { error: resolved.error };
|
||||
}
|
||||
const { tool, args } = resolved;
|
||||
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
|
||||
emit({ type: "tool_call", label: formatCallLabel(tool.name, args), name: tool.name, args });
|
||||
|
||||
// Only `bash` is backgroundable today — the control object is how a mid-flight Ctrl+B (flipped
|
||||
// on session.activeBackground by the UI) reaches into this specific call's polling loop. Reset
|
||||
@@ -381,12 +583,13 @@ async function gateAndRun(
|
||||
session.todos = todos;
|
||||
emit({ type: "todos_update", todos });
|
||||
},
|
||||
taskStore: session.taskStore,
|
||||
};
|
||||
|
||||
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
|
||||
// auto-accept, same as real Claude Code hooks.
|
||||
const hookCtx = { sessionId: session.id, cwd: session.cwd };
|
||||
const preHook = await runHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
|
||||
const preHook = await safeRunHooksForEvent("PreToolUse", hookCtx, { tool_name: tool.name, tool_input: args }, tool.name);
|
||||
emitHookWarnings(preHook.warnings, emit);
|
||||
if (preHook.blocked) {
|
||||
emit({ type: "tool_result", summary: `Blocked by hook: ${preHook.reason}`, isError: true });
|
||||
@@ -405,48 +608,64 @@ async function gateAndRun(
|
||||
return { error: message };
|
||||
}
|
||||
|
||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
||||
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||
const permissionHook = await runHooksForEvent(
|
||||
"PermissionRequest",
|
||||
hookCtx,
|
||||
{ tool_name: tool.name, tool_input: args, preview },
|
||||
tool.name,
|
||||
);
|
||||
emitHookWarnings(permissionHook.warnings, emit);
|
||||
if (permissionHook.blocked) {
|
||||
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
|
||||
return { error: `Blocked by hook: ${permissionHook.reason}` };
|
||||
// Serialize mutating tools across the WHOLE session — including parallel sub-agents spawned
|
||||
// via the `agent` tool's `tasks` array, which share this session's permissions/confirm. Without
|
||||
// this lock two sub-agents running concurrently could each reach a mutating call at once, racing
|
||||
// on the single React `permission` slot (makeConfirmFn) and interleaving filesystem writes. The
|
||||
// gate is a per-session promise chain: each mutating call awaits the previous one before it even
|
||||
// builds its preview, so permission prompts stay one-at-a-time and edits can't overlap. Read-only
|
||||
// tools skip the gate entirely and keep running concurrently (matching runToolBatch).
|
||||
const runMutatingSection = async (): Promise<unknown> => {
|
||||
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
|
||||
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
|
||||
const permissionHook = await safeRunHooksForEvent(
|
||||
"PermissionRequest",
|
||||
hookCtx,
|
||||
{ tool_name: tool.name, tool_input: args, preview },
|
||||
tool.name,
|
||||
);
|
||||
emitHookWarnings(permissionHook.warnings, emit);
|
||||
if (permissionHook.blocked) {
|
||||
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
|
||||
return { error: `Blocked by hook: ${permissionHook.reason}` };
|
||||
}
|
||||
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
|
||||
if (decision === "deny") {
|
||||
emit({ type: "tool_result", summary: "Denied by user", isError: true });
|
||||
return { error: "Denied by user." };
|
||||
}
|
||||
if (decision === "session") {
|
||||
session.permissions.allowForSession(tool.name);
|
||||
}
|
||||
}
|
||||
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
|
||||
if (decision === "deny") {
|
||||
emit({ type: "tool_result", summary: "Denied by user", isError: true });
|
||||
return { error: "Denied by user." };
|
||||
}
|
||||
if (decision === "session") {
|
||||
session.permissions.allowForSession(tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runTool(tool, args, ctx);
|
||||
return runTool(tool, args, ctx);
|
||||
};
|
||||
const result = tool.mutating ? await runUnderMutationGate(session, runMutatingSection) : await runMutatingSection();
|
||||
const isError = !!(result && typeof result === "object" && "error" in (result as object));
|
||||
if (isError) console.error(`[gateAndRun] RUN ERROR: ${tool.name} => ${(result as { error: unknown }).error}`);
|
||||
|
||||
// FileChanged fires for any mutating tool (built-in or MCP/plugin) — whether it touched the
|
||||
// filesystem is left for the hook author to decide (e.g. an API-only mutating MCP tool may be
|
||||
// meaningless here, but a generic mutating tool is the most useful signal locode has). It's
|
||||
// informational only and fires whether the tool succeeded or failed, since the turn is done either way.
|
||||
if (tool.mutating) {
|
||||
// Keep any live LSP server's view of the file in sync with disk so a subsequent
|
||||
// `diagnostics` / `definition` / `references` call reflects the edit. Fire-and-forget
|
||||
// and best-effort (lspManager never throws from this path) — a no-op if no server is
|
||||
// running for this file's language yet.
|
||||
const edited = typeof args === "object" && args !== null && "path" in args ? String((args as { path?: unknown }).path ?? "") : "";
|
||||
if (edited) notifyFileChanged(edited, ctx.cwd).catch(() => {});
|
||||
runHooksForEvent("FileChanged", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result, isError }, tool.name)
|
||||
.then((r) => emitHookWarnings(r.warnings, emit))
|
||||
.catch(() => {});
|
||||
}
|
||||
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
|
||||
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError, name: tool.name, result });
|
||||
|
||||
// PostToolUse can't *veto* a tool call that already ran (unlike PreToolUse), but it IS awaited:
|
||||
// an auto-format hook that rewrites the file the tool just wrote needs to finish before the next
|
||||
// tool runs or the model reads the file again. The tradeoff is that a slow logging hook will stall
|
||||
// the turn up to its own timeout — that's the cost of running formatting/logging synchronously.
|
||||
const postHook = await runHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
|
||||
const postHook = await safeRunHooksForEvent("PostToolUse", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result }, tool.name);
|
||||
emitHookWarnings(postHook.warnings, emit);
|
||||
|
||||
return result;
|
||||
@@ -455,6 +674,61 @@ async function gateAndRun(
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `fn` under the session's mutation gate — a promise chain that serializes every mutating
|
||||
* tool call in the session (including those from parallel sub-agents, which share the parent's
|
||||
* session). The chain is advanced by chaining onto `session.mutationGate` and storing the new tail,
|
||||
* so the next mutating caller waits on this one. Errors don't break the chain (the tail is still
|
||||
* replaced with a settled promise) so one failed edit can't deadlock every later mutation.
|
||||
* Read-only tools skip this entirely and keep running concurrently (matching runToolBatch). */
|
||||
function runUnderMutationGate(session: Session, fn: () => Promise<unknown>): Promise<unknown> {
|
||||
const prev = session.mutationGate;
|
||||
let release!: () => void;
|
||||
const gated = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
session.mutationGate = prev.then(() => gated);
|
||||
return prev
|
||||
.then(() => fn())
|
||||
.finally(() => {
|
||||
release();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a batch of tool calls, parallelizing read-only tools for throughput (a local backend can
|
||||
* serve several independent file reads / greps / web fetches concurrently, where sequential
|
||||
* execution just adds latency), while keeping mutating tools strictly sequential — they share
|
||||
* order-sensitive session state (the permission prompt, `activeBackground`, mutationCommitLength,
|
||||
* FileChanged hooks) that concurrent execution would race on. If every call in the batch is
|
||||
* read-only the whole batch runs concurrently; if any are mutating, the batch runs sequentially to
|
||||
* preserve the existing ordering guarantees (and keep permission prompts in a deterministic order).
|
||||
*
|
||||
* Returns results in the *original* call order regardless of execution order, so callers can push
|
||||
* the corresponding tool-result messages in the order the model emitted the calls.
|
||||
*/
|
||||
async function runToolBatch(
|
||||
calls: { resolved: ResolvedToolCall; label: string }[],
|
||||
session: Session,
|
||||
emit: AgentEventHandler,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown[]> {
|
||||
if (calls.length === 0) return [];
|
||||
const allReadOnly = calls.every((c) => !("error" in c.resolved) && !c.resolved.tool.mutating);
|
||||
const mode = allReadOnly && calls.length > 1 ? "parallel" : "sequential";
|
||||
console.error(`[runToolBatch] ${calls.length} tool(s): ${mode} | ${calls.map((c) => "error" in c.resolved ? `ERR(${c.label.slice(0, 40)})` : `${c.resolved.tool.name}${c.resolved.tool.mutating ? "*" : ""}`).join(", ")}`);
|
||||
if (!allReadOnly || calls.length === 1) {
|
||||
const results: unknown[] = [];
|
||||
for (const c of calls) {
|
||||
results.push(await gateAndRun(c.resolved, c.label, session, emit, signal));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
// All read-only: run concurrently. gateAndRun's shared-state side effects are safe for read-only
|
||||
// tools (no permission prompt, activeBackground stays null, no mutating hooks), and the
|
||||
// tool_call/tool_result emit events interleaving is purely cosmetic.
|
||||
return Promise.all(calls.map((c) => gateAndRun(c.resolved, c.label, session, emit, signal)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a sub-agent as a fresh, isolated turn loop that shares the parent's client/model/cwd/
|
||||
* permissions, but starts with no conversation history beyond the delegated task. Runs headless —
|
||||
@@ -488,7 +762,7 @@ async function runSubAgentTurn(
|
||||
const subToolset = buildToolSet(restricted);
|
||||
const systemPrompt = overrides?.systemPrompt
|
||||
? `${overrides.systemPrompt}\n\nAvailable tools:\n${subToolset.tools.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
|
||||
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
|
||||
: `${buildSystemPrompt(subToolset.tools, parent.mode, parent.projectInstructions, parent.isLocal)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`;
|
||||
const subMessages: ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }];
|
||||
const subSession: Session = {
|
||||
id: randomUUID(),
|
||||
@@ -497,6 +771,7 @@ async function runSubAgentTurn(
|
||||
model: parent.model,
|
||||
cwd: parent.cwd,
|
||||
mode: parent.mode,
|
||||
isLocal: parent.isLocal,
|
||||
messages: subMessages,
|
||||
maxIterations: parent.maxIterations,
|
||||
permissions: parent.permissions,
|
||||
@@ -519,13 +794,19 @@ async function runSubAgentTurn(
|
||||
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
|
||||
// checklist has nowhere to render even if it called todo_write.
|
||||
todos: [],
|
||||
taskStore: new TaskStore(),
|
||||
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
|
||||
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
|
||||
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
|
||||
// sub-agent that doesn't spawn siblings just inherits a resolved chain and is unaffected.
|
||||
mutationGate: parent.mutationGate,
|
||||
};
|
||||
|
||||
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
|
||||
// into the turn's own time budget, and so a hook that blocks (exit 2) actually prevents the
|
||||
// sub-agent from running instead of only being logged.
|
||||
const hookCtx = { sessionId: parent.id, cwd: parent.cwd };
|
||||
const startHook = await runHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
|
||||
const startHook = await safeRunHooksForEvent("SubagentStart", hookCtx, { description: task.description, prompt: task.prompt });
|
||||
if (startHook.warnings.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`SubagentStart hook warnings: ${startHook.warnings.join("; ")}`);
|
||||
@@ -614,10 +895,20 @@ async function handleCompletedMessage(
|
||||
} as ChatCompletionMessageParam);
|
||||
|
||||
const pendingImages: ImageAttachment[] = [];
|
||||
for (const call of message.tool_calls) {
|
||||
const nativeCalls: { resolved: ResolvedToolCall; label: string; call: any }[] = message.tool_calls.map((call: any) => {
|
||||
const resolved = resolveToolCall(call as any, toolset.registry);
|
||||
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
return { resolved, label, call };
|
||||
});
|
||||
const results = await runToolBatch(
|
||||
nativeCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
|
||||
session,
|
||||
emit,
|
||||
signal,
|
||||
);
|
||||
for (let i = 0; i < nativeCalls.length; i++) {
|
||||
const { resolved, call } = nativeCalls[i]!;
|
||||
const result = results[i]!;
|
||||
const image = pushToolResultMessage(session, "native", call.id, call.type === "function" ? call.function.name : call.type, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
@@ -635,12 +926,21 @@ async function handleCompletedMessage(
|
||||
|
||||
if (parsed.calls.length) {
|
||||
session.messages.push({ role: "assistant", content: text });
|
||||
for (const call of parsed.calls) {
|
||||
const fbCalls: { resolved: ResolvedToolCall; label: string; call: { name: string; arguments: any } }[] = parsed.calls.map((call) => {
|
||||
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
return { resolved, label, call };
|
||||
});
|
||||
const fbResults = await runToolBatch(
|
||||
fbCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
|
||||
session,
|
||||
emit,
|
||||
signal,
|
||||
);
|
||||
for (let i = 0; i < fbCalls.length; i++) {
|
||||
const { resolved, call } = fbCalls[i]!;
|
||||
pushToolResultMessage(session, "fallback", "", call.name, fbResults[i]!);
|
||||
noteMutationCommit(session, resolved, fbResults[i]!);
|
||||
}
|
||||
return { text: "", hadToolCalls: true };
|
||||
}
|
||||
@@ -667,6 +967,7 @@ export async function runTurn(
|
||||
if (session.subAgentDepth === 0) session.stats.turns++;
|
||||
session.mutationCommitLength = null;
|
||||
let malformedRetries = 0;
|
||||
let emptyResponseRetries = 0;
|
||||
|
||||
for (let i = 0; i < session.maxIterations; i++) {
|
||||
pruneOldImages(session);
|
||||
@@ -681,13 +982,13 @@ export async function runTurn(
|
||||
try {
|
||||
await compactSession(session);
|
||||
emit({ type: "notice", text: "Context was getting full — auto-compacted mid-turn.", isError: false });
|
||||
// compactSession leaves the history as [system, assistant-recap] with no user turn. Sending
|
||||
// that to the model gives it nothing to respond to — local models routinely answer with an
|
||||
// empty stop (which runTurn then returns as ""), which is exactly why sub-agents that
|
||||
// compacted mid-task came back as "Sub-agent finished (0 chars)", and why a tool-heavy main
|
||||
// turn appeared to hang/stop after compacting. Re-add a user turn so the model resumes the
|
||||
// task instead of going empty. (Between-turn compaction in App.tsx doesn't need this — the
|
||||
// user's next message supplies the turn.)
|
||||
// compactSession now leaves [system, recap, ...preserved tail]. The tail usually ends with
|
||||
// a tool result (role: tool in native mode, a user-role tool_result block in fallback) or an
|
||||
// assistant turn — either way the model needs an explicit cue to resume rather than stop. A
|
||||
// bare [system, recap] used to make local models answer with an empty stop (runTurn returns ""),
|
||||
// which is why sub-agents that compacted mid-task came back "Sub-agent finished (0 chars)" and
|
||||
// tool-heavy main turns appeared to hang. The synthetic user turn fixes that. (Between-turn
|
||||
// compaction in App.tsx doesn't need this — the user's next message supplies the turn.)
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
@@ -702,10 +1003,12 @@ export async function runTurn(
|
||||
// rollback preserves it (dropping it would leave [system, recap] with no user turn again —
|
||||
// the very bug this user message exists to prevent).
|
||||
session.mutationCommitLength = session.messages.length;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Best-effort: if compaction itself fails, proceed with the oversized context rather than
|
||||
// aborting the whole turn — the idle-abort guard on the next request still protects against
|
||||
// an outright hang, it'll just be a slower/costlier request.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[compactSession] compaction failed (non-fatal):", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +1021,12 @@ export async function runTurn(
|
||||
const requestStart = Date.now();
|
||||
|
||||
const idleGuard = createIdleAbort(resolveRequestTimeoutMs(), signal);
|
||||
// Independent from idleGuard — a repetition loop keeps producing chunks, so idleGuard's
|
||||
// silence-based timer never trips. Combined below via AbortSignal.any so either guard can end
|
||||
// the request; only this one needs its own controller since it fires from content, not silence.
|
||||
const repetitionAbort = new AbortController();
|
||||
let repetitionDetected = false;
|
||||
let lastRepetitionCheckLength = 0;
|
||||
try {
|
||||
const stream = await session.client.chat.completions.create(
|
||||
{
|
||||
@@ -726,9 +1035,9 @@ export async function runTurn(
|
||||
tools: session.mode === "native" ? toolset.openaiTools : undefined,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: 4096,
|
||||
max_tokens: resolveMaxTokens(session),
|
||||
},
|
||||
{ signal: idleGuard.signal },
|
||||
{ signal: AbortSignal.any([idleGuard.signal, repetitionAbort.signal]) },
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -755,6 +1064,15 @@ export async function runTurn(
|
||||
if (delta?.content) {
|
||||
fullText += delta.content;
|
||||
emit({ type: "text_delta", delta: delta.content });
|
||||
// Throttled to roughly every 100 new characters — detectRepetitionLoop is cheap per call,
|
||||
// but there's no reason to run it on every single-token chunk.
|
||||
if (fullText.length - lastRepetitionCheckLength >= 100) {
|
||||
lastRepetitionCheckLength = fullText.length;
|
||||
if (detectRepetitionLoop(fullText)) {
|
||||
repetitionDetected = true;
|
||||
repetitionAbort.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate native tool call deltas
|
||||
@@ -784,6 +1102,11 @@ export async function runTurn(
|
||||
if (fullText) {
|
||||
emit({ type: "text_done", fullText });
|
||||
}
|
||||
if (repetitionDetected) {
|
||||
throw new AgentError(
|
||||
"Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.",
|
||||
);
|
||||
}
|
||||
if (idleGuard.didTimeOut()) {
|
||||
throw new AgentError(
|
||||
`Backend stopped responding mid-stream (no data for ${Math.round(resolveRequestTimeoutMs() / 1000)}s) — connection aborted. The backend may have crashed or hung; try again.`,
|
||||
@@ -794,6 +1117,15 @@ export async function runTurn(
|
||||
idleGuard.dispose();
|
||||
}
|
||||
|
||||
// A repetition-triggered abort can also surface as a clean (chunk-less) end of the async
|
||||
// iterator instead of a thrown error, depending on how far into the stream it landed — check
|
||||
// unconditionally rather than only in the catch above, mirroring the idle-timeout check below.
|
||||
if (repetitionDetected) {
|
||||
throw new AgentError(
|
||||
"Detected a repetition loop in the model's response and aborted early — the model got stuck repeating itself. Try again; if it keeps happening, this model may need a different prompt or a lower temperature.",
|
||||
);
|
||||
}
|
||||
|
||||
// An idle-triggered abort can also surface as a clean (chunk-less) end of the async iterator
|
||||
// instead of a thrown error, depending on how far into the stream it landed — check unconditionally
|
||||
// rather than only in the catch above, or it'd silently fall through to "Empty response from model."
|
||||
@@ -809,13 +1141,23 @@ export async function runTurn(
|
||||
|
||||
// --- Handle native tool calls from streaming ---
|
||||
if (session.mode === "native" && accumulatedToolCalls.length > 0) {
|
||||
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
|
||||
// retry the entire turn non-streaming
|
||||
// Validate accumulated arguments. Local-model streaming (Ollama/LM Studio deltas)
|
||||
// commonly truncates the JSON (max_tokens clipped mid-value) or leaves a trailing
|
||||
// comma / unbalanced brace. Before paying for a full non-streaming regeneration of
|
||||
// the turn (expensive on a local backend, and it fails identically when the cause was
|
||||
// max_tokens), try a cheap structural repair — close unterminated strings, balance
|
||||
// braces/brackets, strip stray trailing tokens. A repaired call still goes through
|
||||
// schema validation at resolve time, so a bad repair can't execute a wrong call.
|
||||
let allValid = true;
|
||||
for (const tc of accumulatedToolCalls) {
|
||||
try {
|
||||
JSON.parse(tc.arguments);
|
||||
} catch {
|
||||
const repaired = repairPartialJson(tc.arguments);
|
||||
if (repaired !== null) {
|
||||
tc.arguments = JSON.stringify(repaired);
|
||||
continue; // repaired successfully — keep checking the rest
|
||||
}
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
@@ -837,7 +1179,7 @@ export async function runTurn(
|
||||
messages: session.messages,
|
||||
tools: toolset.openaiTools,
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
max_tokens: resolveMaxTokens(session),
|
||||
},
|
||||
{ signal: retryGuard.signal },
|
||||
);
|
||||
@@ -881,13 +1223,23 @@ export async function runTurn(
|
||||
} as ChatCompletionMessageParam);
|
||||
|
||||
const pendingImages: ImageAttachment[] = [];
|
||||
for (const tc of accumulatedToolCalls) {
|
||||
const streamNativeCalls: { resolved: ResolvedToolCall; label: string; tc: AccumulatedToolCall }[] = accumulatedToolCalls.map((tc) => {
|
||||
const resolved = resolveToolCall(
|
||||
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
|
||||
toolset.registry,
|
||||
);
|
||||
const label = `${tc.name}(${tc.arguments})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
return { resolved, label, tc };
|
||||
});
|
||||
const streamResults = await runToolBatch(
|
||||
streamNativeCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
|
||||
session,
|
||||
emit,
|
||||
signal,
|
||||
);
|
||||
for (let i = 0; i < streamNativeCalls.length; i++) {
|
||||
const { resolved, tc } = streamNativeCalls[i]!;
|
||||
const result = streamResults[i]!;
|
||||
const image = pushToolResultMessage(session, "native", tc.id, tc.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
if (image) pendingImages.push(image);
|
||||
@@ -905,12 +1257,21 @@ export async function runTurn(
|
||||
if (parsed.calls.length) {
|
||||
emit({ type: "text_done", fullText });
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
for (const call of parsed.calls) {
|
||||
const streamFbCalls: { resolved: ResolvedToolCall; label: string; call: { name: string; arguments: any } }[] = parsed.calls.map((call) => {
|
||||
const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
|
||||
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
|
||||
const result = await gateAndRun(resolved, label, session, emit, signal);
|
||||
pushToolResultMessage(session, "fallback", "", call.name, result);
|
||||
noteMutationCommit(session, resolved, result);
|
||||
return { resolved, label, call };
|
||||
});
|
||||
const streamFbResults = await runToolBatch(
|
||||
streamFbCalls.map((c) => ({ resolved: c.resolved, label: c.label })),
|
||||
session,
|
||||
emit,
|
||||
signal,
|
||||
);
|
||||
for (let i = 0; i < streamFbCalls.length; i++) {
|
||||
const { resolved, call } = streamFbCalls[i]!;
|
||||
pushToolResultMessage(session, "fallback", "", call.name, streamFbResults[i]!);
|
||||
noteMutationCommit(session, resolved, streamFbResults[i]!);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -924,6 +1285,19 @@ export async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// A bare empty `stop` (no text, no tool calls) — common from small/quantized local models.
|
||||
// Treat it as retryable rather than a final empty answer: nudge once and continue, so a
|
||||
// transient empty response doesn't end the task with no output (mirroring malformed path).
|
||||
if (!fullText.trim()) {
|
||||
if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
|
||||
emptyResponseRetries++;
|
||||
session.messages.push({ role: "user", content: "You returned an empty response. Please continue with your answer or call a tool." } as ChatCompletionMessageParam);
|
||||
continue;
|
||||
}
|
||||
// Retry budget exhausted — surface a hard error instead of silently returning "".
|
||||
throw new AgentError("Empty response from model.");
|
||||
}
|
||||
|
||||
// Final text answer
|
||||
emit({ type: "text_done", fullText });
|
||||
session.messages.push({ role: "assistant", content: fullText });
|
||||
@@ -931,11 +1305,21 @@ export async function runTurn(
|
||||
return fullText;
|
||||
}
|
||||
|
||||
// Empty response with no tool calls and no text — this shouldn't happen normally
|
||||
// Empty response with no tool calls and no text. Small/quantized local models sometimes
|
||||
// emit a bare empty `stop` chunk; retry once with a short nudge before giving up, so a
|
||||
// transient empty response doesn't abort the whole turn (mirroring the malformed path).
|
||||
if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
|
||||
emptyResponseRetries++;
|
||||
session.messages.push({ role: "user", content: "You returned an empty response. Please continue with your answer or call a tool." } as ChatCompletionMessageParam);
|
||||
continue;
|
||||
}
|
||||
throw new AgentError("Empty response from model.");
|
||||
}
|
||||
|
||||
throw new MaxIterationsError(
|
||||
`Paused after ${session.maxIterations} steps in this turn. Everything done so far (including any file edits) is saved — send another message to continue.`,
|
||||
`Paused after ${session.maxIterations} steps in this turn (each step is one model request). ` +
|
||||
`Everything done so far — including any file edits or tool results — is saved in the conversation. ` +
|
||||
`Send another message to continue from where it stopped (e.g. "continue" or "keep going"). ` +
|
||||
`If this happens often, raise the limit with: locode config set maxIterations <number>.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { runTurn } from "./loop.js";
|
||||
import { agentTool } from "../tools/agentTool.js";
|
||||
import { createSession } from "./session.js";
|
||||
import { buildToolSet } from "../tools/toolset.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
|
||||
/** A one-shot streaming response: yields `chunk` once, then ends. */
|
||||
function oneShotStream(chunk: any) {
|
||||
let yielded = false;
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (yielded) return { done: true, value: undefined };
|
||||
yielded = true;
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function textChunk(text: string) {
|
||||
return { choices: [{ delta: { content: text }, finish_reason: "stop" }] };
|
||||
}
|
||||
|
||||
function toolCallChunk(name: string, args: string, id = "call_0") {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: { tool_calls: [{ index: 0, id, function: { name, arguments: args } }] },
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent tool / parallel sub-agents", () => {
|
||||
it("runs a `tasks` batch and returns each result independently", async () => {
|
||||
const agentArgs = JSON.stringify({
|
||||
description: "parallel research",
|
||||
tasks: [
|
||||
{ description: "task A", prompt: "do A" },
|
||||
{ description: "task B", prompt: "do B" },
|
||||
],
|
||||
});
|
||||
|
||||
// create() is called: 1 parent agent-call, then sub-agent A, then sub-agent B, then parent final.
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
call++;
|
||||
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
|
||||
if (call === 2) return oneShotStream(textChunk("result-A"));
|
||||
if (call === 3) return oneShotStream(textChunk("result-B"));
|
||||
return oneShotStream(textChunk("all done"));
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
|
||||
|
||||
const result = await runTurn(session, "research A and B in parallel", () => {});
|
||||
expect(result).toBe("all done");
|
||||
|
||||
// The agent tool's result must carry both sub-agent answers as a `results` array.
|
||||
const toolResultMsg = session.messages.find(
|
||||
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
|
||||
) as any;
|
||||
expect(toolResultMsg).toBeTruthy();
|
||||
const parsed = JSON.parse(toolResultMsg.content);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
|
||||
expect(byDesc["task A"].result).toBe("result-A");
|
||||
expect(byDesc["task B"].result).toBe("result-B");
|
||||
});
|
||||
|
||||
it("a failed sub-agent surfaces as its own error without discarding sibling results", async () => {
|
||||
const agentArgs = JSON.stringify({
|
||||
description: "mixed batch",
|
||||
tasks: [
|
||||
{ description: "ok", prompt: "succeed" },
|
||||
{ description: "boom", prompt: "fail" },
|
||||
],
|
||||
});
|
||||
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
call++;
|
||||
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
|
||||
if (call === 2) return oneShotStream(textChunk("ok-result"));
|
||||
if (call === 3) throw new Error("sub-agent boom failed");
|
||||
return oneShotStream(textChunk("done"));
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
|
||||
|
||||
await runTurn(session, "run mixed batch", () => {});
|
||||
|
||||
const toolResultMsg = session.messages.find(
|
||||
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
|
||||
) as any;
|
||||
expect(toolResultMsg).toBeTruthy();
|
||||
const parsed = JSON.parse(toolResultMsg.content);
|
||||
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
|
||||
expect(byDesc.ok.result).toBe("ok-result");
|
||||
expect(byDesc.boom.error).toBeTruthy();
|
||||
expect(byDesc.boom.error).toMatch(/boom/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutation gate / serialization", () => {
|
||||
it("serializes mutating tool calls so two never run concurrently", async () => {
|
||||
let inFlight = 0;
|
||||
let maxOverlap = 0;
|
||||
const slowEdit: ToolDef = {
|
||||
name: "slow_edit",
|
||||
description: "slow edit",
|
||||
schema: z.object({}),
|
||||
mutating: true,
|
||||
handler: async () => {
|
||||
inFlight++;
|
||||
maxOverlap = Math.max(maxOverlap, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
inFlight--;
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
function twoEditsChunk() {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "c0", function: { name: "slow_edit", arguments: "{}" } },
|
||||
{ index: 1, id: "c1", function: { name: "slow_edit", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
call++;
|
||||
const chunk = call === 1 ? twoEditsChunk() : textChunk("done");
|
||||
return oneShotStream(chunk);
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [slowEdit]);
|
||||
session.maxIterations = 5;
|
||||
|
||||
await runTurn(session, "two edits", () => {});
|
||||
expect(maxOverlap).toBe(1);
|
||||
});
|
||||
|
||||
it("lets read-only tools run concurrently (gate only blocks mutating)", async () => {
|
||||
let inFlight = 0;
|
||||
let maxOverlap = 0;
|
||||
const fastRead: ToolDef = {
|
||||
name: "fast_read",
|
||||
description: "fast read",
|
||||
schema: z.object({}),
|
||||
mutating: false,
|
||||
handler: async () => {
|
||||
inFlight++;
|
||||
maxOverlap = Math.max(maxOverlap, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
inFlight--;
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
function threeReadsChunk() {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "c0", function: { name: "fast_read", arguments: "{}" } },
|
||||
{ index: 1, id: "c1", function: { name: "fast_read", arguments: "{}" } },
|
||||
{ index: 2, id: "c2", function: { name: "fast_read", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let call = 0;
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(async () => {
|
||||
call++;
|
||||
const chunk = call === 1 ? threeReadsChunk() : textChunk("done");
|
||||
return oneShotStream(chunk);
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [fastRead]);
|
||||
session.maxIterations = 5;
|
||||
|
||||
await runTurn(session, "three reads", () => {});
|
||||
// All three reads are read-only → runToolBatch runs them concurrently → they overlap.
|
||||
expect(maxOverlap).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { undoLastTurn, resetSession } from "./session.js";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
|
||||
function makeSession(messages: ChatCompletionMessageParam[]) {
|
||||
return {
|
||||
messages,
|
||||
lastContextTokens: 0,
|
||||
lastContextTokensIsEstimate: true,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("undoLastTurn", () => {
|
||||
it("returns 0 when there are no user messages", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
]);
|
||||
expect(undoLastTurn(session)).toBe(0);
|
||||
});
|
||||
|
||||
it("removes a single user turn at the end", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
]);
|
||||
const removed = undoLastTurn(session);
|
||||
expect(removed).toBe(2);
|
||||
expect(session.messages.length).toBe(1);
|
||||
expect(session.messages[0]!.role).toBe("system");
|
||||
});
|
||||
|
||||
it("removes user + assistant + tool results together", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Read the file" },
|
||||
{ role: "assistant", content: "", tool_calls: [{ id: "tc1", type: "function", function: { name: "read_file", arguments: "{}" } }] } as any,
|
||||
{ role: "tool", content: "file contents here", tool_call_id: "tc1" } as any,
|
||||
{ role: "assistant", content: "The file contains..." },
|
||||
]);
|
||||
const removed = undoLastTurn(session);
|
||||
expect(removed).toBe(4);
|
||||
expect(session.messages.length).toBe(1);
|
||||
});
|
||||
|
||||
it("only removes the last turn, keeping earlier turns", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "First question" },
|
||||
{ role: "assistant", content: "First answer" },
|
||||
{ role: "user", content: "Second question" },
|
||||
{ role: "assistant", content: "Second answer" },
|
||||
]);
|
||||
const removed = undoLastTurn(session);
|
||||
expect(removed).toBe(2);
|
||||
expect(session.messages.length).toBe(3);
|
||||
expect((session.messages[2] as any).content).toBe("First answer");
|
||||
});
|
||||
|
||||
it("handles consecutive user messages (removing only the last one)", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Message 1" },
|
||||
{ role: "user", content: "Message 2" },
|
||||
]);
|
||||
const removed = undoLastTurn(session);
|
||||
expect(removed).toBe(1);
|
||||
expect(session.messages.length).toBe(2);
|
||||
expect((session.messages[1] as any).content).toBe("Message 1");
|
||||
});
|
||||
|
||||
it("updates context token tracking after undo", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi!" },
|
||||
]);
|
||||
session.lastContextTokens = 5000;
|
||||
session.lastContextTokensIsEstimate = false;
|
||||
undoLastTurn(session);
|
||||
expect(session.lastContextTokensIsEstimate).toBe(true);
|
||||
// lastContextTokens should be recalculated (smaller than before)
|
||||
expect(session.lastContextTokens).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetSession", () => {
|
||||
it("clears all messages except system prompt", () => {
|
||||
const session = makeSession([
|
||||
{ role: "system", content: "You are helpful." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi!" },
|
||||
]);
|
||||
resetSession(session);
|
||||
expect(session.messages.length).toBe(1);
|
||||
expect(session.messages[0]!.role).toBe("system");
|
||||
});
|
||||
});
|
||||
+77
-9
@@ -2,13 +2,15 @@ import { randomUUID } from "node:crypto";
|
||||
import type OpenAI from "openai";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js";
|
||||
import type { SessionRecord } from "../persistence/sessionStore.js";
|
||||
import { PermissionManager } from "../permissions/permissionManager.js";
|
||||
import type { ConfirmFn } from "../permissions/types.js";
|
||||
import { TOOLS } from "../tools/index.js";
|
||||
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
|
||||
import type { TodoItem, ToolDef } from "../tools/types.js";
|
||||
import { TaskStore } from "../tools/task.js";
|
||||
import type { TaskStoreSnapshot } from "../tools/task.js";
|
||||
import { estimateTokens } from "../utils/tokens.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
|
||||
@@ -86,6 +88,26 @@ export interface Session {
|
||||
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
|
||||
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
|
||||
todos: TodoItem[];
|
||||
/** Structured task store backing the task_create/list/get/update tools — session-scoped,
|
||||
* in-memory (not persisted), and independent of the flat `todos` checklist. */
|
||||
taskStore: TaskStore;
|
||||
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
|
||||
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
|
||||
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
|
||||
* race on the single React `permission` slot (makeConfirmFn), and interleave filesystem writes.
|
||||
* This gate lets read-only tools run concurrently (matching runToolBatch) while serializing
|
||||
* mutating ones: each mutating call awaits the previous one before it even prompts, so
|
||||
* permission prompts stay one-at-a-time and edits can't overlap. The chain is per-session,
|
||||
* so a top-level turn and its sub-agents all funnel through the same queue. Initialized as a
|
||||
* resolved promise so the first caller doesn't wait on anything. */
|
||||
mutationGate: Promise<void>;
|
||||
/** True when this is a small/less-capable local model, not just a local *backend* — Ollama's
|
||||
* cloud-routed models (e.g. "glm-5.2:cloud") share a localhost endpoint with genuinely local
|
||||
* ones, so this is more than a baseURL check. Used to tailor the system prompt — small local
|
||||
* models need extra guidance about their limitations; everything else gets a leaner prompt
|
||||
* without self-fulfilling "you may produce empty responses" framing. Derived by
|
||||
* isSmallLocalModel(baseURL, model); recomputed on /model and /backend switches (see App.tsx). */
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
@@ -95,15 +117,18 @@ export function createSession(
|
||||
confirm: ConfirmFn,
|
||||
mode: ToolCallMode,
|
||||
tools: ToolDef[] = TOOLS,
|
||||
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
|
||||
contextWindow?: number,
|
||||
contextWindowIsEstimate: boolean = true,
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
projectInstructions: string | null = null,
|
||||
isLocal?: boolean,
|
||||
): Session {
|
||||
const resolvedIsLocal = isLocal ?? false;
|
||||
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
const toolset = buildToolSet(tools);
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions) },
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, mode, projectInstructions, resolvedIsLocal) },
|
||||
];
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -112,13 +137,14 @@ export function createSession(
|
||||
model,
|
||||
cwd,
|
||||
mode,
|
||||
isLocal: resolvedIsLocal,
|
||||
messages,
|
||||
maxIterations,
|
||||
permissions: new PermissionManager(),
|
||||
confirm,
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
contextWindow: resolvedContextWindow,
|
||||
contextWindowIsEstimate,
|
||||
lastContextTokens: estimateTokens(messages),
|
||||
lastContextTokensIsEstimate: true,
|
||||
@@ -128,6 +154,8 @@ export function createSession(
|
||||
mutationCommitLength: null,
|
||||
projectInstructions,
|
||||
todos: [],
|
||||
taskStore: new TaskStore(),
|
||||
mutationGate: Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,31 +167,35 @@ export function createSessionFromRecord(
|
||||
cwd: string,
|
||||
confirm: ConfirmFn,
|
||||
tools: ToolDef[] = TOOLS,
|
||||
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
|
||||
contextWindow?: number,
|
||||
contextWindowIsEstimate: boolean = true,
|
||||
maxIterations: number = DEFAULT_MAX_ITERATIONS,
|
||||
autoCompactThreshold: number = DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
projectInstructions: string | null = null,
|
||||
isLocal?: boolean,
|
||||
): Session {
|
||||
const resolvedIsLocal = isLocal ?? false;
|
||||
const resolvedContextWindow = contextWindow ?? (resolvedIsLocal ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
const toolset = buildToolSet(tools);
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions) },
|
||||
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions, resolvedIsLocal) },
|
||||
...record.messages,
|
||||
];
|
||||
return {
|
||||
const session: Session = {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
client,
|
||||
model: record.model,
|
||||
cwd,
|
||||
mode: record.mode,
|
||||
isLocal: resolvedIsLocal,
|
||||
messages,
|
||||
maxIterations,
|
||||
permissions: new PermissionManager(),
|
||||
confirm,
|
||||
toolset,
|
||||
subAgentDepth: 0,
|
||||
contextWindow,
|
||||
contextWindow: resolvedContextWindow,
|
||||
contextWindowIsEstimate,
|
||||
lastContextTokens: estimateTokens(messages),
|
||||
lastContextTokensIsEstimate: true,
|
||||
@@ -173,7 +205,18 @@ export function createSessionFromRecord(
|
||||
mutationCommitLength: null,
|
||||
projectInstructions,
|
||||
todos: [],
|
||||
taskStore: TaskStore.fromJSON(record.tasks ?? { seq: 0, tasks: [] }),
|
||||
mutationGate: Promise.resolve(),
|
||||
};
|
||||
|
||||
// Restore session-allowed tools from the saved record, so /perm approvals survive resume.
|
||||
if (record.allowedTools) {
|
||||
for (const toolName of record.allowedTools) {
|
||||
session.permissions.allowForSession(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function toSessionRecord(session: Session, baseURL: string): SessionRecord {
|
||||
@@ -186,6 +229,8 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
|
||||
model: session.model,
|
||||
mode: session.mode,
|
||||
messages: session.messages.slice(1),
|
||||
allowedTools: session.permissions.listAllowed(),
|
||||
tasks: session.taskStore.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,7 +240,30 @@ export function resetSession(session: Session): void {
|
||||
session.lastContextTokensIsEstimate = true;
|
||||
}
|
||||
|
||||
/** Removes the last complete user turn (the user message + all subsequent assistant/tool messages
|
||||
* up to the next user message or the end of history). Returns the number of messages removed,
|
||||
* or 0 if there's no user message to undo (only the system prompt remains). This is a soft undo \u2014
|
||||
* filesystem changes from tool calls are NOT rolled back, but the model will no longer see the
|
||||
* removed context, so it won't repeat those actions. */
|
||||
export function undoLastTurn(session: Session): number {
|
||||
// Walk backwards from the end to find the last user message.
|
||||
let lastUserIdx = -1;
|
||||
for (let i = session.messages.length - 1; i >= 1; i--) {
|
||||
if (session.messages[i]!.role === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx === -1) return 0; // No user messages to undo.
|
||||
|
||||
const removed = session.messages.length - lastUserIdx;
|
||||
session.messages.length = lastUserIdx;
|
||||
session.lastContextTokens = estimateTokens(session.messages);
|
||||
session.lastContextTokensIsEstimate = true;
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function setMode(session: Session, mode: ToolCallMode): void {
|
||||
session.mode = mode;
|
||||
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions) };
|
||||
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions, session.isLocal) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
import { buildSystemPrompt } from "./systemPrompt.js";
|
||||
|
||||
const dummyTool = (name: string, mutating: boolean): ToolDef => ({
|
||||
name,
|
||||
description: `Tool ${name} for testing`,
|
||||
schema: z.object({}),
|
||||
mutating,
|
||||
handler: async () => null,
|
||||
});
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
const readTool = dummyTool("read_file", false);
|
||||
const writeTool = dummyTool("write_file", true);
|
||||
|
||||
it("includes tool names grouped by category", () => {
|
||||
const prompt = buildSystemPrompt([readTool, writeTool], "native");
|
||||
expect(prompt).toContain("read_file");
|
||||
expect(prompt).toContain("write_file");
|
||||
expect(prompt).toContain("Read-only");
|
||||
expect(prompt).toContain("Mutating");
|
||||
});
|
||||
|
||||
it("includes core principles for local models", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, true);
|
||||
expect(prompt).toContain("Inspect before answering");
|
||||
expect(prompt).toContain("Prefer small, targeted edits");
|
||||
expect(prompt).toContain("Recovery over retry");
|
||||
expect(prompt).toContain("Respect confirmation");
|
||||
});
|
||||
|
||||
it("includes core principles for cloud models", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, false);
|
||||
expect(prompt).toContain("Inspect before answering");
|
||||
expect(prompt).toContain("Prefer small, targeted edits");
|
||||
expect(prompt).toContain("Recovery over retry");
|
||||
expect(prompt).toContain("Respect confirmation");
|
||||
});
|
||||
|
||||
it("includes tool usage guide", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native");
|
||||
expect(prompt).toContain("read_file");
|
||||
expect(prompt).toContain("edit_file");
|
||||
expect(prompt).toContain("bash");
|
||||
expect(prompt).toContain("agent");
|
||||
});
|
||||
|
||||
it("includes local model guidance when isLocal is true", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, true);
|
||||
expect(prompt).toContain("Working with local models");
|
||||
expect(prompt).toContain("Tool-call formatting can be unreliable");
|
||||
expect(prompt).toContain("Empty or malformed responses can happen");
|
||||
});
|
||||
|
||||
it("excludes local model guidance when isLocal is false (cloud)", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, false);
|
||||
expect(prompt).not.toContain("Working with local models");
|
||||
expect(prompt).not.toContain("Tool-call formatting can be unreliable");
|
||||
expect(prompt).not.toContain("Empty or malformed responses can happen");
|
||||
});
|
||||
|
||||
it("defaults to cloud prompt when isLocal is not specified", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native");
|
||||
expect(prompt).toContain("coding assistant");
|
||||
expect(prompt).not.toContain("Working with local models");
|
||||
});
|
||||
|
||||
it("includes safety guidelines for both local and cloud", () => {
|
||||
const localPrompt = buildSystemPrompt([readTool], "native", null, true);
|
||||
const cloudPrompt = buildSystemPrompt([readTool], "native", null, false);
|
||||
expect(localPrompt).toContain(".git");
|
||||
expect(localPrompt).toContain("destructive");
|
||||
expect(cloudPrompt).toContain(".git");
|
||||
expect(cloudPrompt).toContain("destructive");
|
||||
});
|
||||
|
||||
it("includes fallback instructions when mode is fallback", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "fallback");
|
||||
expect(prompt).toContain("tool_call");
|
||||
expect(prompt.toLowerCase()).toContain("fallback");
|
||||
});
|
||||
|
||||
it("includes native mode instructions when mode is native (local)", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, true);
|
||||
expect(prompt).toContain("native tool-call mode");
|
||||
});
|
||||
|
||||
it("includes native mode instructions when mode is native (cloud)", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null, false);
|
||||
expect(prompt).toContain("native tool-call mode");
|
||||
});
|
||||
|
||||
it("appends project instructions", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", "Always use TypeScript strict mode.");
|
||||
expect(prompt).toContain("Always use TypeScript strict mode.");
|
||||
// Project instructions should be at the end
|
||||
const idx = prompt.indexOf("Always use TypeScript strict mode.");
|
||||
const safetyIdx = prompt.indexOf("## Safety");
|
||||
expect(idx).toBeGreaterThan(safetyIdx);
|
||||
});
|
||||
|
||||
it("works without project instructions", () => {
|
||||
const prompt = buildSystemPrompt([readTool], "native", null);
|
||||
expect(prompt).not.toContain("Project instructions");
|
||||
});
|
||||
|
||||
it("handles empty tool list", () => {
|
||||
const prompt = buildSystemPrompt([], "native");
|
||||
expect(prompt).toContain("Available tools");
|
||||
expect(prompt).toContain("Core principles");
|
||||
});
|
||||
|
||||
it("handles all read-only tools", () => {
|
||||
const tools = [dummyTool("read_file", false), dummyTool("grep", false), dummyTool("definition", false)];
|
||||
const prompt = buildSystemPrompt(tools, "native");
|
||||
// Tool list section should only have Read-only
|
||||
const toolSection = prompt.split("## Core principles")[0];
|
||||
expect(toolSection).toContain("Read-only: read_file, grep, definition");
|
||||
expect(toolSection).not.toContain("Mutating");
|
||||
});
|
||||
|
||||
it("handles all mutating tools", () => {
|
||||
const tools = [dummyTool("write_file", true), dummyTool("edit_file", true)];
|
||||
const prompt = buildSystemPrompt(tools, "native");
|
||||
const toolSection = prompt.split("## Core principles")[0];
|
||||
expect(toolSection).toContain("Mutating (requires confirmation): write_file, edit_file");
|
||||
expect(toolSection).not.toContain("Read-only");
|
||||
});
|
||||
});
|
||||
+135
-12
@@ -2,20 +2,143 @@ import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
|
||||
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null): string {
|
||||
const toolList = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
|
||||
const base = `You are a helpful local coding assistant with access to tools for exploring a codebase on the user's machine.
|
||||
function formatToolList(tools: ToolDef[]): string {
|
||||
const readWrite = new Map<string, string[]>();
|
||||
for (const t of tools) {
|
||||
const category = t.mutating ? "Mutating (requires confirmation)" : "Read-only";
|
||||
const list = readWrite.get(category) ?? [];
|
||||
list.push(t.name);
|
||||
readWrite.set(category, list);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const [category, names] of readWrite) {
|
||||
parts.push(`${category}: ${names.join(", ")}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null, isLocal?: boolean): string {
|
||||
const toolList = formatToolList(tools);
|
||||
|
||||
// Auto-detect: if not explicitly specified, use the cloud prompt by default.
|
||||
// Callers (App.tsx) always pass isSmallLocalModel(baseURL, model) explicitly, so this
|
||||
// default only affects tests or edge cases without a baseURL/model.
|
||||
const useLocal = isLocal ?? false;
|
||||
const base = useLocal
|
||||
? buildLocalPrompt(toolList, mode)
|
||||
: buildCloudPrompt(toolList, mode);
|
||||
|
||||
return projectInstructions ? `${base}\n\n${projectInstructions}` : base;
|
||||
}
|
||||
|
||||
/** System prompt for local models (Ollama / LM Studio) — includes extra guidance about their
|
||||
* limitations (unreliable tool-call formatting, occasional empty/malformed responses).
|
||||
* Cloud models get a leaner prompt (buildCloudPrompt) that omits these assumptions. */
|
||||
function buildLocalPrompt(toolList: string, mode: ToolCallMode): string {
|
||||
return `You are a helpful coding assistant with access to tools for exploring and editing a codebase on the user's machine.
|
||||
|
||||
## Available tools
|
||||
|
||||
Available tools:
|
||||
${toolList}
|
||||
|
||||
Guidelines:
|
||||
- Inspect files with tools before answering; don't guess contents.
|
||||
- Call at most one tool at a time.
|
||||
- Mutating tools (write_file, edit_file, bash, git_commit) require user confirmation.
|
||||
- Use edit_file for small edits; write_file for new files or full rewrites.
|
||||
- Respond in plain text when you have enough information. Keep answers concise.`;
|
||||
## Core principles
|
||||
|
||||
const withMode = mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
|
||||
return projectInstructions ? `${withMode}\n\n${projectInstructions}` : withMode;
|
||||
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
|
||||
|
||||
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
|
||||
|
||||
3. **One tool call per response in fallback mode.** If you are in fallback mode (see below), call at most one tool per response and wait for the result before proceeding. In native mode you may call multiple read-only tools in parallel.
|
||||
|
||||
4. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
|
||||
|
||||
5. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
|
||||
|
||||
6. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
|
||||
|
||||
7. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
|
||||
|
||||
## Tool usage guide
|
||||
|
||||
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
|
||||
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
|
||||
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
|
||||
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
|
||||
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
|
||||
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
|
||||
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
|
||||
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible. Use timeout_ms for long-running commands. Background with Ctrl+B for very long commands.
|
||||
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
|
||||
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
|
||||
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
|
||||
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
|
||||
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
|
||||
|
||||
## Working with local models
|
||||
|
||||
- **Tool-call formatting can be unreliable.** If you're in fallback mode, follow the tool_call format strictly. If native mode produces errors, the system will automatically retry with fallback parsing.
|
||||
- **Empty or malformed responses can happen.** The system retries automatically, but if you see repeated failures, simplify your request.
|
||||
- **Output length may be limited.** For large file generations, prefer edit_file over write_file when possible — it uses fewer output tokens.
|
||||
|
||||
## Fallback mode
|
||||
|
||||
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not modify .git directories or other version-control internals.
|
||||
- Do not delete large sections of code without clear justification and user confirmation.
|
||||
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
|
||||
- If unsure about a destructive action, ask the user first rather than proceeding.`;
|
||||
}
|
||||
|
||||
/** System prompt for cloud models (large context window, reliable tool calls, no local-model quirks).
|
||||
* Leaner than the local prompt — skips the "Working with local models" section entirely and uses
|
||||
* a more direct tone, since cloud models don't need hand-holding about their own limitations. */
|
||||
function buildCloudPrompt(toolList: string, mode: ToolCallMode): string {
|
||||
return `You are a coding assistant with access to tools for exploring and editing a codebase on the user's machine.
|
||||
|
||||
## Available tools
|
||||
|
||||
${toolList}
|
||||
|
||||
## Core principles
|
||||
|
||||
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
|
||||
|
||||
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
|
||||
|
||||
3. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
|
||||
|
||||
4. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
|
||||
|
||||
5. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
|
||||
|
||||
6. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
|
||||
|
||||
## Tool usage guide
|
||||
|
||||
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
|
||||
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
|
||||
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
|
||||
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
|
||||
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
|
||||
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
|
||||
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
|
||||
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible. Use timeout_ms for long-running commands. Background with Ctrl+B for very long commands.
|
||||
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
|
||||
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
|
||||
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
|
||||
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
|
||||
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
|
||||
|
||||
## ${mode === "fallback" ? "Fallback mode" : "Tool calling"}
|
||||
|
||||
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not modify .git directories or other version-control internals.
|
||||
- Do not delete large sections of code without clear justification and user confirmation.
|
||||
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
|
||||
- If unsure about a destructive action, ask the user first rather than proceeding.`;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { ToolCallMode } from "./capabilityProbe.js";
|
||||
const paths = envPaths("locode", { suffix: "" });
|
||||
const cacheFile = path.join(paths.config, "model-capabilities.json");
|
||||
|
||||
type Cache = Record<string, ToolCallMode>;
|
||||
type Cache = Record<string, { mode: ToolCallMode; cachedAt?: number }>;
|
||||
|
||||
function keyFor(baseURL: string, model: string): string {
|
||||
return `${baseURL}::${model}`;
|
||||
@@ -23,7 +23,19 @@ function load(): Cache {
|
||||
// Always re-read from disk if possible, so concurrent processes' writes aren't overwritten.
|
||||
if (existsSync(cacheFile)) {
|
||||
try {
|
||||
memoryCache = JSON.parse(readFileSync(cacheFile, "utf-8")) as Cache;
|
||||
const raw = JSON.parse(readFileSync(cacheFile, "utf-8")) as Record<string, unknown>;
|
||||
// Migrate legacy format: bare string values ("native" | "fallback") become { mode, cachedAt }.
|
||||
const cache: Cache = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof v === "string" && (v === "native" || v === "fallback")) {
|
||||
// Legacy entry — no cachedAt, so it can be re-validated on next probe.
|
||||
cache[k] = { mode: v };
|
||||
} else if (typeof v === "object" && v !== null && "mode" in v) {
|
||||
cache[k] = v as Cache[string];
|
||||
}
|
||||
// Silently drop unrecognized entries.
|
||||
}
|
||||
memoryCache = cache;
|
||||
return memoryCache;
|
||||
} catch {
|
||||
memoryCache = {};
|
||||
@@ -41,12 +53,36 @@ function save(cache: Cache): void {
|
||||
void writeFileAtomic(cacheFile, JSON.stringify(cache, null, 2));
|
||||
}
|
||||
|
||||
/** How long a cached tool-call mode detection stays fresh before locode re-probes. A model's
|
||||
* tool-call capability rarely changes, but a transient probe failure (network timeout, 5xx)
|
||||
* can leave a stale "fallback" entry that permanently disables native tool calls. The TTL
|
||||
* ensures periodic re-validation. Set to 0 via LOCODE_CAPABILITY_CACHE_TTL_DAYS=0 to force
|
||||
* a re-probe every session. */
|
||||
const DEFAULT_CACHE_TTL_DAYS = 30;
|
||||
|
||||
function resolveCacheTtlDays(): number {
|
||||
const envValue = Number(process.env.LOCODE_CAPABILITY_CACHE_TTL_DAYS);
|
||||
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 365) return envValue;
|
||||
return DEFAULT_CACHE_TTL_DAYS;
|
||||
}
|
||||
|
||||
function isStale(entry: { cachedAt?: number }, ttlDays: number): boolean {
|
||||
if (ttlDays <= 0) return true;
|
||||
if (typeof entry.cachedAt !== "number") return true; // legacy entry — always re-probe
|
||||
const ageMs = Date.now() - entry.cachedAt;
|
||||
return ageMs > ttlDays * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function getCachedMode(baseURL: string, model: string): ToolCallMode | undefined {
|
||||
return load()[keyFor(baseURL, model)];
|
||||
const entry = load()[keyFor(baseURL, model)];
|
||||
if (entry === undefined) return undefined;
|
||||
// Treat stale or legacy entries as a miss so the backend is re-probed.
|
||||
if (isStale(entry, resolveCacheTtlDays())) return undefined;
|
||||
return entry.mode;
|
||||
}
|
||||
|
||||
export function setCachedMode(baseURL: string, model: string, mode: ToolCallMode): void {
|
||||
const cache = load();
|
||||
cache[keyFor(baseURL, model)] = mode;
|
||||
cache[keyFor(baseURL, model)] = { mode, cachedAt: Date.now() };
|
||||
save(cache);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenAI from "openai";
|
||||
import { resolveRequestTimeoutMs } from "../config/config.js";
|
||||
import { resolveMaxRetries, resolveRequestTimeoutMs } from "../config/config.js";
|
||||
import type { AppConfig } from "../config/types.js";
|
||||
|
||||
export function makeClient(cfg: AppConfig): OpenAI {
|
||||
@@ -15,6 +15,10 @@ export function makeClient(cfg: AppConfig): OpenAI {
|
||||
// requests behind a concurrency limit (e.g. Ollama's OLLAMA_NUM_PARALLEL) can legitimately take
|
||||
// longer than the 180s default to even start serving a request under contention.
|
||||
timeout: resolveRequestTimeoutMs(),
|
||||
maxRetries: 0,
|
||||
// Configurable retries on transient failures (connection errors, 429, 5xx) with exponential
|
||||
// backoff. Defaults to 0 (fail immediately) to preserve the old behavior, since a local
|
||||
// backend's slow response usually means the model is stuck rather than a transient blip — but
|
||||
// raise via `maxRetries` / LOCODE_MAX_RETRIES for setups with occasional connection drops.
|
||||
maxRetries: resolveMaxRetries(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,9 +61,14 @@ async function detectLmStudioContextWindow(baseURL: string, model: string): Prom
|
||||
* assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather
|
||||
* than guessing) if neither responds usefully — callers should fall back to a configured default. */
|
||||
export async function detectContextWindow(baseURL: string, model: string): Promise<number | null> {
|
||||
const ollama = await detectOllamaContextWindow(baseURL, model);
|
||||
if (ollama !== null) return ollama;
|
||||
return detectLmStudioContextWindow(baseURL, model);
|
||||
// Try both backends in parallel to halve detection latency.
|
||||
const [ollama, lmStudio] = await Promise.allSettled([
|
||||
detectOllamaContextWindow(baseURL, model),
|
||||
detectLmStudioContextWindow(baseURL, model),
|
||||
]);
|
||||
if (ollama.status === "fulfilled" && ollama.value !== null) return ollama.value;
|
||||
if (lmStudio.status === "fulfilled" && lmStudio.value !== null) return lmStudio.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ResolvedContextWindow {
|
||||
@@ -85,5 +90,5 @@ export async function resolveContextWindow(baseURL: string, model: string): Prom
|
||||
return { value: detected, isEstimate: false };
|
||||
}
|
||||
|
||||
return { value: resolveContextWindowDefault(), isEstimate: true };
|
||||
return { value: resolveContextWindowDefault(baseURL, model), isEstimate: true };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import envPaths from "env-paths";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getCachedContextWindow } from "./contextWindowCache.js";
|
||||
|
||||
const KEY = "http://localhost:11434/v1::ttl-model";
|
||||
|
||||
const cacheFile = path.join(envPaths("locode", { suffix: "" }).config, "context-windows.json");
|
||||
|
||||
describe("contextWindowCache", () => {
|
||||
@@ -19,16 +21,18 @@ describe("contextWindowCache", () => {
|
||||
} else if (existsSync(cacheFile)) {
|
||||
rmSync(cacheFile);
|
||||
}
|
||||
delete process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS;
|
||||
});
|
||||
|
||||
it("reads a well-formed cached entry", () => {
|
||||
// Written directly (rather than via setCachedContextWindow, whose write is fire-and-forget
|
||||
// async and would race this file-backed cache's always-read-from-disk load()) so the test is
|
||||
// deterministic and can't leak a pending write past its own afterEach cleanup.
|
||||
// deterministic and can't leak a pending write past its own afterEach cleanup. Includes a
|
||||
// fresh cachedAt so the TTL check treats it as current.
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, JSON.stringify({ "http://localhost:11434/v1::test-model": { value: 32768, isEstimate: false } }), "utf-8");
|
||||
writeFileSync(cacheFile, JSON.stringify({ "http://localhost:11434/v1::test-model": { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
|
||||
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "test-model")).toEqual({ value: 32768, isEstimate: false });
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "test-model")).toEqual({ value: 32768, isEstimate: false, cachedAt: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("treats a legacy bare-number cache entry as a miss instead of returning {value: undefined}", () => {
|
||||
@@ -42,4 +46,41 @@ describe("contextWindowCache", () => {
|
||||
const result = getCachedContextWindow("http://localhost:11434/v1", "legacy-model");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats an entry without cachedAt as expired (re-detect)", () => {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false } }), "utf-8");
|
||||
// No cachedAt field — legacy entry from before TTL was added; should be a miss.
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a fresh entry (recent cachedAt) as a hit", () => {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toEqual({ value: 32768, isEstimate: false, cachedAt: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("treats an old entry (cachedAt beyond TTL) as a miss", () => {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
// 30 days ago, default TTL is 7 days — stale.
|
||||
const old = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: old } }), "utf-8");
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects a configured TTL of 0 (always re-detect)", () => {
|
||||
process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS = "0";
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: Date.now() } }), "utf-8");
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects a longer configured TTL", () => {
|
||||
process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS = "365";
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
// 30 days ago, but TTL is now 365 days — fresh.
|
||||
const old = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
writeFileSync(cacheFile, JSON.stringify({ [KEY]: { value: 32768, isEstimate: false, cachedAt: old } }), "utf-8");
|
||||
expect(getCachedContextWindow("http://localhost:11434/v1", "ttl-model")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,23 @@ import { writeFileAtomic } from "../utils/writeFileAtomic.js";
|
||||
const paths = envPaths("locode", { suffix: "" });
|
||||
const cacheFile = path.join(paths.config, "context-windows.json");
|
||||
|
||||
/** How long a cached context-window detection stays fresh before locode re-detects it. A model's
|
||||
* context window rarely changes, but a backend can be reconfigured (quantization swapped, a
|
||||
* different model loaded under the same id, Ollama's `num_ctx` raised) — a TTL avoids pinning a
|
||||
* stale value forever. Set to 0 to disable caching (re-detect every session). */
|
||||
const DEFAULT_CACHE_TTL_DAYS = 7;
|
||||
|
||||
export function resolveCacheTtlDays(): number {
|
||||
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW_CACHE_TTL_DAYS);
|
||||
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 365) return envValue;
|
||||
return DEFAULT_CACHE_TTL_DAYS;
|
||||
}
|
||||
|
||||
export interface CachedContextWindow {
|
||||
value: number;
|
||||
isEstimate: boolean;
|
||||
/** Unix epoch ms when this entry was cached. Absent on legacy entries (treated as expired). */
|
||||
cachedAt?: number;
|
||||
}
|
||||
|
||||
type Cache = Record<string, CachedContextWindow>;
|
||||
@@ -39,7 +53,17 @@ function save(cache: Cache): void {
|
||||
void writeFileAtomic(cacheFile, JSON.stringify(cache, null, 2));
|
||||
}
|
||||
|
||||
/** Returns true when the entry is stale given the configured TTL. A TTL of 0 means "always
|
||||
* re-detect", so every entry is stale; a missing `cachedAt` (legacy entry) is also stale. */
|
||||
function isStale(entry: CachedContextWindow, ttlDays: number): boolean {
|
||||
if (ttlDays <= 0) return true;
|
||||
if (typeof entry.cachedAt !== "number") return true;
|
||||
const ageMs = Date.now() - entry.cachedAt;
|
||||
return ageMs > ttlDays * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function getCachedContextWindow(baseURL: string, model: string): CachedContextWindow | undefined {
|
||||
const ttlDays = resolveCacheTtlDays();
|
||||
const entry = load()[keyFor(baseURL, model)];
|
||||
if (entry === undefined) return undefined;
|
||||
// An older locode version cached a bare number instead of { value, isEstimate }. Treat that
|
||||
@@ -50,11 +74,13 @@ export function getCachedContextWindow(baseURL: string, model: string): CachedCo
|
||||
if (typeof entry !== "object" || entry === null || typeof (entry as CachedContextWindow).value !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
// Expired entries are treated as a miss so the backend is re-queried and the entry refreshed.
|
||||
if (isStale(entry, ttlDays)) return undefined;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setCachedContextWindow(baseURL: string, model: string, contextWindow: CachedContextWindow): void {
|
||||
const cache = load();
|
||||
cache[keyFor(baseURL, model)] = contextWindow;
|
||||
cache[keyFor(baseURL, model)] = { ...contextWindow, cachedAt: Date.now() };
|
||||
save(cache);
|
||||
}
|
||||
}
|
||||
+45
-6
@@ -2,13 +2,14 @@ import { execa } from "execa";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { makeClient } from "./backend/client.js";
|
||||
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
|
||||
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
|
||||
import { loadMergedHooks, userHooksFilePath } from "./hooks/config.js";
|
||||
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
|
||||
import { isHttpServerConfig } from "./mcp/types.js";
|
||||
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
|
||||
import { deleteSession, listSessions, loadSession, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
|
||||
import { addInstalledPlugin, loadInstalledPlugins, pluginsDir, removeInstalledPlugin } from "./plugins/config.js";
|
||||
import { loadPlugin } from "./plugins/loader.js";
|
||||
import { runInkApp } from "./ui/ink/index.js";
|
||||
@@ -24,7 +25,7 @@ function addBackendOptions(cmd: Command): Command {
|
||||
program
|
||||
.name("locode")
|
||||
.description("Agentic coding CLI for local models via Ollama and LM Studio")
|
||||
.version("0.3.1");
|
||||
.version(pkg.version);
|
||||
|
||||
addBackendOptions(program)
|
||||
.option("-m, --model <name>", "model name as known to the backend")
|
||||
@@ -114,31 +115,57 @@ configCmd
|
||||
|
||||
configCmd
|
||||
.command("set <key> <value>")
|
||||
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs)")
|
||||
.description(
|
||||
"Persist a config value (backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers)",
|
||||
)
|
||||
.action((key: string, value: string) => {
|
||||
if (
|
||||
key !== "backend" &&
|
||||
key !== "model" &&
|
||||
key !== "baseUrl" &&
|
||||
key !== "contextWindow" &&
|
||||
key !== "maxOutputTokens" &&
|
||||
key !== "maxIterations" &&
|
||||
key !== "autoCompactThreshold" &&
|
||||
key !== "requestTimeoutMs" &&
|
||||
key !== "subagentTimeoutMs"
|
||||
key !== "subagentTimeoutMs" &&
|
||||
key !== "maxRetries" &&
|
||||
key !== "lspServers"
|
||||
) {
|
||||
console.error(
|
||||
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs`,
|
||||
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const stored = loadStoredConfig();
|
||||
if (key === "contextWindow" || key === "maxIterations") {
|
||||
if (key === "lspServers") {
|
||||
// lspServers is a JSON object: { "<languageId>": { "command": "...", "args": [...], "extensions": [...] } }
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
console.error(`lspServers must be a JSON object, got invalid JSON: ${value}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
console.error(`lspServers must be a JSON object keyed by language id, got: ${value}`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored.lspServers = parsed as Record<string, { command: string; args?: string[]; extensions?: string[] }>;
|
||||
} else if (key === "contextWindow" || key === "maxIterations") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
console.error(`${key} must be a positive number, got "${value}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "maxOutputTokens") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 256 || n > 1_000_000) {
|
||||
console.error(`maxOutputTokens must be between 256 and 1000000, got "${value}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "autoCompactThreshold") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0.1 || n > 0.95) {
|
||||
@@ -160,6 +187,13 @@ configCmd
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else if (key === "maxRetries") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 10) {
|
||||
console.error(`maxRetries must be between 0 and 10, got "${value}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
stored[key] = n;
|
||||
} else {
|
||||
stored[key] = value;
|
||||
}
|
||||
@@ -196,6 +230,11 @@ sessionsCmd
|
||||
.action((id: string) => {
|
||||
if (deleteSession(id)) {
|
||||
console.log(`Deleted session ${id}.`);
|
||||
} else if (loadSession(id)) {
|
||||
// The file exists but deleteSession() couldn't actually remove it (e.g. locked by another
|
||||
// process) — a different situation from "no such session", so say so distinctly.
|
||||
console.error(`Could not delete session "${id}" — the file may be in use by another process.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(`No saved session found with id "${id}".`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { configureLanguageSpecs, _resetSpecsForTests, _specsForTests } from "./lspManager.js";
|
||||
|
||||
// configureLanguageSpecs mutates the module's LANGUAGE_SPECS (forward-only by design — production
|
||||
// applies it once at startup). Tests restore the built-in defaults via _resetSpecsForTests so they
|
||||
// stay independent, then assert the merged spec list through _specsForTests (no server spawned).
|
||||
|
||||
function specFor(ext: string) {
|
||||
const specs = _specsForTests();
|
||||
return specs.find((s) => s.extensions.includes(ext)) ?? null;
|
||||
}
|
||||
|
||||
describe("configureLanguageSpecs", () => {
|
||||
beforeEach(() => _resetSpecsForTests());
|
||||
afterEach(() => _resetSpecsForTests());
|
||||
|
||||
it("leaves the built-in specs untouched for an empty override", () => {
|
||||
configureLanguageSpecs({});
|
||||
expect(_specsForTests().map((s) => s.languageId)).toEqual([
|
||||
"typescript",
|
||||
"python",
|
||||
"go",
|
||||
"rust",
|
||||
"c",
|
||||
]);
|
||||
// C and C++ share one clangd spec (no separate "cpp" entry).
|
||||
expect(specFor(".cpp")?.languageId).toBe("c");
|
||||
expect(specFor(".h")?.languageId).toBe("c");
|
||||
});
|
||||
|
||||
it("adds a brand-new language with extensions", () => {
|
||||
configureLanguageSpecs({ java: { command: "jdtls", extensions: [".java"] } });
|
||||
expect(specFor(".java")?.command).toBe("jdtls");
|
||||
expect(specFor(".java")?.languageId).toBe("java");
|
||||
});
|
||||
|
||||
it("ignores a new-language entry without extensions (can't route files to it)", () => {
|
||||
configureLanguageSpecs({ ruby: { command: "solargraph" } });
|
||||
expect(specFor(".rb")).toBeNull();
|
||||
});
|
||||
|
||||
it("overrides a built-in server's command and args", () => {
|
||||
configureLanguageSpecs({ typescript: { command: "my-tsserver", args: ["--stdio"] } });
|
||||
expect(specFor(".ts")?.command).toBe("my-tsserver");
|
||||
expect(specFor(".ts")?.args).toEqual(["--stdio"]);
|
||||
});
|
||||
|
||||
it("keeps a built-in's extensions when an override omits them", () => {
|
||||
configureLanguageSpecs({ python: { command: "basedpyright", args: ["--stdio"] } });
|
||||
expect(specFor(".py")?.command).toBe("basedpyright");
|
||||
expect(specFor(".pyi")?.languageId).toBe("python"); // extensions unchanged
|
||||
});
|
||||
|
||||
it("rewrites a built-in language's extensions when provided", () => {
|
||||
configureLanguageSpecs({ go: { command: "gopls", args: ["serve"], extensions: [".rs"] } });
|
||||
// .rs now routes to "go", not "rust".
|
||||
expect(specFor(".rs")?.languageId).toBe("go");
|
||||
expect(specFor(".go")).toBeNull(); // .go no longer claimed by go
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { readFile as fsReadFile } from "node:fs/promises";
|
||||
import {
|
||||
createProtocolConnection,
|
||||
DidChangeTextDocumentNotification,
|
||||
DidOpenTextDocumentNotification,
|
||||
DefinitionRequest,
|
||||
ReferencesRequest,
|
||||
type ProtocolConnection,
|
||||
type TextDocumentIdentifier,
|
||||
type Position,
|
||||
type Location,
|
||||
type Diagnostic,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import { StreamMessageReader, StreamMessageWriter } from 'vscode-languageserver-protocol/node';
|
||||
import { URI } from "vscode-uri";
|
||||
import type { MarkupContent } from "vscode-languageserver-protocol";
|
||||
|
||||
/** LSP diagnostic messages can be either a plain string or a { kind, value } MarkupContent object.
|
||||
* locode's tool surface deals in plain strings, so flatten either form to text. */
|
||||
function messageToString(message: string | MarkupContent): string {
|
||||
if (typeof message === "string") return message;
|
||||
return message?.value ?? "";
|
||||
}
|
||||
|
||||
/** A connected LSP server for one language, plus its child process so we can clean it up. */
|
||||
interface LspHandle {
|
||||
connection: ProtocolConnection;
|
||||
child: ChildProcess;
|
||||
languageId: string;
|
||||
/** Open documents we've already sent didOpen for, so we send didChange (not didOpen) on edits. */
|
||||
openDocs: Set<string>;
|
||||
}
|
||||
|
||||
/** Maps a file extension to a language id (the LSP "languageId" string) and the server command to
|
||||
* spawn for it. Only one server per language is ever spawned (lazy, on first use). A missing entry
|
||||
* means locode has no built-in mapping — the user can still point a server at it via config in a
|
||||
* future extension. The command is resolved on the PATH; if it isn't installed the spawn fails and
|
||||
* the tool returns a clear "install X" error rather than a silent no-op. */
|
||||
interface LanguageSpec {
|
||||
languageId: string;
|
||||
extensions: string[];
|
||||
/** The server command (no args). Must be on PATH. */
|
||||
command: string;
|
||||
/** Args passed to the server command. */
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
// The built-in language→server mappings. Mutable so `configureLanguageSpecs` can merge in user
|
||||
// overrides/additions from config (see config.ts `lspServers`). One clangd spec covers both C
|
||||
// and C++ — clangd handles both, and merging avoids spawning a second clangd for a mixed C/C++
|
||||
// project (two servers keyed by separate languageIds would each index the same headers twice).
|
||||
let LANGUAGE_SPECS: LanguageSpec[] = [
|
||||
// TypeScript / JavaScript — `typescript-language-server` wraps tsserver and speaks LSP. The most
|
||||
// common local-model codebase shape, so it's the first one locode wires up.
|
||||
{
|
||||
languageId: "typescript",
|
||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
command: "typescript-language-server",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
{
|
||||
languageId: "python",
|
||||
extensions: [".py", ".pyi"],
|
||||
command: "pyright-langserver",
|
||||
args: ["--stdio"],
|
||||
},
|
||||
{
|
||||
languageId: "go",
|
||||
extensions: [".go"],
|
||||
command: "gopls",
|
||||
args: ["serve"],
|
||||
},
|
||||
{
|
||||
languageId: "rust",
|
||||
extensions: [".rs"],
|
||||
command: "rust-analyzer",
|
||||
},
|
||||
// C and C++ share clangd. The languageId is "c" (clangd treats .cpp/.hpp the same way);
|
||||
// all C/C++ extensions route to the single clangd process.
|
||||
{
|
||||
languageId: "c",
|
||||
extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
|
||||
command: "clangd",
|
||||
},
|
||||
];
|
||||
|
||||
/** Merge user-configured LSP server entries (from `locode config set lspServers`) into the
|
||||
* built-in specs. An entry keyed by a built-in languageId overrides that spec's command/args
|
||||
* and, if `extensions` is provided, which file extensions route to it. An entry keyed by a new
|
||||
* languageId (e.g. "java", "ruby") adds a brand-new mapping — it MUST supply `extensions` so
|
||||
* files can be routed to it. Call once at startup; idempotent against the built-in list.
|
||||
*
|
||||
* Entries missing a `command` are ignored (a server we can't spawn is useless), and entries for
|
||||
* new ids without `extensions` are ignored too (no way to route files to them). */
|
||||
export function configureLanguageSpecs(overrides: Record<string, { command: string; args?: string[]; extensions?: string[] }>): void {
|
||||
const merged: LanguageSpec[] = LANGUAGE_SPECS.map((spec) => {
|
||||
const ov = overrides[spec.languageId];
|
||||
if (!ov) return spec;
|
||||
return {
|
||||
languageId: spec.languageId,
|
||||
extensions: ov.extensions ?? spec.extensions,
|
||||
command: ov.command,
|
||||
args: ov.args,
|
||||
};
|
||||
});
|
||||
for (const [languageId, ov] of Object.entries(overrides)) {
|
||||
if (merged.some((s) => s.languageId === languageId)) continue; // already a built-in we overrode
|
||||
if (!ov.command || !ov.extensions || ov.extensions.length === 0) continue;
|
||||
merged.push({ languageId, extensions: ov.extensions, command: ov.command, args: ov.args });
|
||||
}
|
||||
LANGUAGE_SPECS = merged;
|
||||
}
|
||||
|
||||
/** Picks the LanguageSpec for a file path, or null if no extension matches. */
|
||||
function specForFile(filePath: string): LanguageSpec | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!ext) return null;
|
||||
return LANGUAGE_SPECS.find((s) => s.extensions.includes(ext)) ?? null;
|
||||
}
|
||||
|
||||
/** A per-workspace (cwd) registry of live LSP servers, keyed by language id. One server per
|
||||
* language per cwd — a second project gets its own manager (locode is single-session-per-process
|
||||
* today, but keying on cwd keeps it correct if that ever changes). */
|
||||
const handles = new Map<string, LspHandle>();
|
||||
|
||||
/** Convert an absolute filesystem path to an LSP file:// URI string. */
|
||||
function toUri(absPath: string): string {
|
||||
return URI.file(absPath).toString();
|
||||
}
|
||||
|
||||
interface LocResult {
|
||||
path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
function toLocation(loc: Location): LocResult {
|
||||
return {
|
||||
path: URI.parse(loc.uri).fsPath,
|
||||
line: loc.range.start.line + 1,
|
||||
column: loc.range.start.character + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spawns the LSP server for `spec`, initializes it, and returns a live handle. Throws a clear,
|
||||
* actionable error if the server binary isn't on the PATH (the most common failure) so the tool
|
||||
* can surface "install typescript-language-server" instead of an opaque spawn ENOENT. */
|
||||
async function startServer(spec: LanguageSpec, cwd: string): Promise<LspHandle> {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
// npm installs global CLI packages on Windows as .cmd/.ps1 shims, not raw .exe files — spawn()
|
||||
// can't resolve those without shell:true, so a genuinely-installed server would otherwise ENOENT.
|
||||
child = spawn(spec.command, spec.args ?? [], {
|
||||
cwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
// spawn() itself rarely throws synchronously — a missing binary (ENOENT) instead fires an
|
||||
// async 'error' event on the child process. With no listener, that event is unhandled and
|
||||
// crashes the whole process, so wait for either a successful spawn or that error before
|
||||
// proceeding.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("spawn", () => resolve());
|
||||
child.once("error", (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
// After startup, a late 'error' (e.g. the process dying unexpectedly) must not go unhandled
|
||||
// either — the 'exit' handler below already disposes the connection, so just swallow it here.
|
||||
child.on("error", () => {});
|
||||
if (!child.stdin || !child.stdout) {
|
||||
child.kill();
|
||||
throw new Error(`LSP server "${spec.command}" did not open stdio streams.`);
|
||||
}
|
||||
|
||||
const reader = new StreamMessageReader(child.stdout);
|
||||
const writer = new StreamMessageWriter(child.stdin);
|
||||
const connection = createProtocolConnection(reader, writer);
|
||||
// vscode-jsonrpc buffers all messages until listen() starts pumping them — without this,
|
||||
// sendRequest hangs (nothing is ever written) or throws "Call listen() first."
|
||||
connection.listen();
|
||||
|
||||
// Surface stderr so a crashing server isn't a silent void (matches locode's MCP stdio policy).
|
||||
child.stderr?.on("data", () => {
|
||||
// Discard by default; a future debug mode could surface this. Don't let it back up.
|
||||
});
|
||||
|
||||
await connection.sendRequest("initialize", {
|
||||
processId: process.pid,
|
||||
rootUri: URI.file(cwd).toString(),
|
||||
capabilities: {
|
||||
// locode consumes definition/references/diagnostics; declare only those so a server doesn't
|
||||
// waste effort enabling features we'll never query. Full text sync (change=1) is simplest and
|
||||
// correct — we always resend the whole file, never a range edit.
|
||||
textDocumentSync: { openClose: true, change: 1 },
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
},
|
||||
workspaceFolders: [{ uri: URI.file(cwd).toString(), name: path.basename(cwd) || cwd }],
|
||||
});
|
||||
// Per LSP spec, the client must send `initialized` after the initialize response.
|
||||
await connection.sendNotification("initialized", {});
|
||||
|
||||
// A server crash should reject any in-flight request rather than hanging forever — listen for
|
||||
// exit and dispose the connection so the next call throws instead of awaiting a dead process.
|
||||
child.on("exit", () => {
|
||||
connection.dispose();
|
||||
handles.delete(`${cwd}::${spec.languageId}`);
|
||||
});
|
||||
|
||||
return { connection, child, languageId: spec.languageId, openDocs: new Set() };
|
||||
}
|
||||
|
||||
/** Returns the (lazily-started) LSP handle for the language owning `filePath`, or throws if no
|
||||
* server is configured/can't start. The first call for a language pays the initialize round-trip;
|
||||
* every later call reuses the live server. */
|
||||
async function handleForFile(filePath: string, cwd: string): Promise<LspHandle> {
|
||||
const spec = specForFile(filePath);
|
||||
if (!spec) {
|
||||
throw new Error(`No LSP server configured for "${path.extname(filePath)}" (code intelligence supports: ${LANGUAGE_SPECS.map((s) => s.extensions[0]).join(", ")}).`);
|
||||
}
|
||||
const key = `${cwd}::${spec.languageId}`;
|
||||
let handle = handles.get(key);
|
||||
if (!handle) {
|
||||
handle = await startServer(spec, cwd);
|
||||
handles.set(key, handle);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Ensures the LSP server knows the current on-disk contents of `filePath`. Sends didOpen the
|
||||
* first time a file is touched, didChange on subsequent syncs (the file was edited on disk since).
|
||||
* Reads the file fresh each time — locode's tools write to disk before this runs, so the disk is
|
||||
* the source of truth, not any in-memory buffer. */
|
||||
async function syncDocument(handle: LspHandle, absPath: string, cwd: string): Promise<void> {
|
||||
const uri = toUri(absPath);
|
||||
const content = await fsReadFile(absPath, "utf-8");
|
||||
if (!handle.openDocs.has(uri)) {
|
||||
await handle.connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
||||
textDocument: { uri, languageId: handle.languageId, version: 1, text: content },
|
||||
});
|
||||
handle.openDocs.add(uri);
|
||||
} else {
|
||||
await handle.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
||||
textDocument: { uri, version: Date.now() },
|
||||
contentChanges: [{ text: content }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface DefinitionResult {
|
||||
/** The file/line/column of the symbol's definition. Multiple entries if the symbol has more than
|
||||
* one definition (interface implementations, overloads, partial classes). Empty if the server
|
||||
* found none (undefined symbol, or the server couldn't resolve it). */
|
||||
definitions: LocResult[];
|
||||
}
|
||||
|
||||
/** Resolves where the symbol at `line`/`column` (1-indexed) in `filePath` is defined. Syncs the
|
||||
* document first so the server's view matches disk. Returns an empty list (not an error) when the
|
||||
* server has no definition to offer — that's a legitimate "not found", not a failure. */
|
||||
export async function getDefinition(
|
||||
filePath: string,
|
||||
line: number,
|
||||
column: number,
|
||||
cwd: string,
|
||||
): Promise<DefinitionResult> {
|
||||
const absPath = path.resolve(cwd, filePath);
|
||||
const handle = await handleForFile(filePath, cwd);
|
||||
await syncDocument(handle, absPath, cwd);
|
||||
const pos: Position = { line: line - 1, character: column - 1 };
|
||||
const result = (await handle.connection.sendRequest(DefinitionRequest.type, {
|
||||
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
|
||||
position: pos,
|
||||
})) as Location | Location[] | null;
|
||||
const locs = Array.isArray(result) ? result : result ? [result] : [];
|
||||
return { definitions: locs.map(toLocation) };
|
||||
}
|
||||
|
||||
export interface ReferencesResult {
|
||||
/** Every place the symbol at `line`/`column` is referenced (including its definition). */
|
||||
references: LocResult[];
|
||||
}
|
||||
|
||||
/** Finds every reference to the symbol at `line`/`column` in `filePath`. `includeDeclaration`
|
||||
* defaults to true (matches most IDE "find all references" behavior). */
|
||||
export async function getReferences(
|
||||
filePath: string,
|
||||
line: number,
|
||||
column: number,
|
||||
cwd: string,
|
||||
includeDeclaration = true,
|
||||
): Promise<ReferencesResult> {
|
||||
const absPath = path.resolve(cwd, filePath);
|
||||
const handle = await handleForFile(filePath, cwd);
|
||||
await syncDocument(handle, absPath, cwd);
|
||||
const pos: Position = { line: line - 1, character: column - 1 };
|
||||
const result = (await handle.connection.sendRequest(ReferencesRequest.type, {
|
||||
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
|
||||
position: pos,
|
||||
context: { includeDeclaration },
|
||||
})) as Location[] | null;
|
||||
return { references: (result ?? []).map(toLocation) };
|
||||
}
|
||||
|
||||
/** Notifies the LSP server that `filePath` changed on disk, so a subsequent `diagnostics` call
|
||||
* reflects the new content. Called from the FileChanged hook path after edit_file/write_file. If no
|
||||
* server is running for this language (or the file isn't one we manage), this is a no-op — it must
|
||||
* never throw from a hook context, since hooks fire on every mutating tool. */
|
||||
export async function notifyFileChanged(filePath: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
const spec = specForFile(filePath);
|
||||
if (!spec) return;
|
||||
const key = `${cwd}::${spec.languageId}`;
|
||||
const handle = handles.get(key);
|
||||
if (!handle) return; // No server started yet — diagnostics will sync on first query.
|
||||
await syncDocument(handle, path.resolve(cwd, filePath), cwd);
|
||||
} catch {
|
||||
// Best-effort: a hook context can't propagate errors into the turn.
|
||||
}
|
||||
}
|
||||
|
||||
type Severity = "error" | "warning" | "information" | "hint";
|
||||
|
||||
export interface DiagnosticsResult {
|
||||
diagnostics: { path: string; line: number; column: number; severity: Severity; message: string; source?: string }[];
|
||||
}
|
||||
|
||||
/** The most recent diagnostics the server has published for `filePath`. LSP pushes diagnostics via
|
||||
* `textDocument/publishDiagnostics` notifications; locode collects them per-URI as they arrive and
|
||||
* returns the latest snapshot here. Forces a document sync first so the snapshot is current. */
|
||||
const diagnosticsByUri = new Map<string, Diagnostic[]>();
|
||||
|
||||
// Per-URI resolvers waiting on the next publishDiagnostics notification. getDiagnostics arms one
|
||||
// for the file it just synced, then races it against a timeout — so a slow server (tsserver on a
|
||||
// large file) still gets a chance to publish the fresh snapshot rather than the caller reading a
|
||||
// stale one after a single event-loop turn. Resolved and cleared by the publishDiagnostics handler.
|
||||
const diagWaiters = new Map<string, () => void>();
|
||||
|
||||
/** Wait for the next publishDiagnostics for `uri`, or give up after `timeoutMs`. Resolves true if
|
||||
* a publish arrived, false on timeout. The waiter is removed either way. */
|
||||
function waitForDiagnostics(uri: string, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
diagWaiters.delete(uri);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
diagWaiters.set(uri, () => {
|
||||
clearTimeout(timer);
|
||||
diagWaiters.delete(uri);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SEVERITY_MAP: Record<number, Severity> = {
|
||||
1: "error",
|
||||
2: "warning",
|
||||
3: "information",
|
||||
4: "hint",
|
||||
};
|
||||
|
||||
export async function getDiagnostics(filePath: string, cwd: string): Promise<DiagnosticsResult> {
|
||||
const absPath = path.resolve(cwd, filePath);
|
||||
const handle = await handleForFile(filePath, cwd);
|
||||
const uri = toUri(absPath);
|
||||
// Attach a per-connection diagnostic collector the first time we use this handle.
|
||||
if (!(handle as unknown as { __diagWired?: boolean }).__diagWired) {
|
||||
(handle as unknown as { __diagWired?: boolean }).__diagWired = true;
|
||||
handle.connection.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
|
||||
diagnosticsByUri.set(params.uri, params.diagnostics);
|
||||
// Wake a getDiagnostics call waiting on this URI, if any.
|
||||
diagWaiters.get(params.uri)?.();
|
||||
});
|
||||
}
|
||||
// Clear any stale snapshot for this URI before syncing so a timeout fallthrough can't return
|
||||
// diagnostics from before the edit. The server publishes asynchronously after didChange; race
|
||||
// its next publish against a short timeout so a slow server (tsserver on a large file) still
|
||||
// gets a chance to compute fresh diagnostics rather than us reading a stale snapshot after one
|
||||
// event-loop turn. Fall through to whatever's cached on timeout (possibly empty).
|
||||
diagnosticsByUri.delete(uri);
|
||||
await syncDocument(handle, absPath, cwd);
|
||||
await waitForDiagnostics(uri, 1500);
|
||||
const diags = diagnosticsByUri.get(uri) ?? [];
|
||||
return {
|
||||
diagnostics: diags.map((d) => ({
|
||||
path: URI.parse(uri).fsPath,
|
||||
line: (d.range?.start.line ?? 0) + 1,
|
||||
column: (d.range?.start.character ?? 0) + 1,
|
||||
severity: SEVERITY_MAP[d.severity ?? 1] ?? "information",
|
||||
message: messageToString(d.message ?? ""),
|
||||
source: d.source,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shuts down every live LSP server. Call on locode exit so spawned servers (tsserver, pyright,
|
||||
* gopls, …) don't outlive the process as orphans. Awaits each shutdown so the signals land before
|
||||
* teardown. Best-effort: a stuck server can't block exit forever (the child kill still fires). */
|
||||
export async function shutdownAll(): Promise<void> {
|
||||
const all = [...handles.values()];
|
||||
handles.clear();
|
||||
await Promise.allSettled(
|
||||
all.map(async (h) => {
|
||||
try {
|
||||
await h.connection.sendRequest("shutdown", null);
|
||||
h.connection.sendNotification("exit", {});
|
||||
} catch {
|
||||
// Already dead — fall through to kill.
|
||||
}
|
||||
h.child.kill();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** For tests only: clear the live-handle registry and diagnostic cache without spawning/killing. */
|
||||
export function _resetForTests(): void {
|
||||
handles.clear();
|
||||
diagnosticsByUri.clear();
|
||||
diagWaiters.clear();
|
||||
}
|
||||
|
||||
/** For tests only: a snapshot of the currently configured language specs (after any
|
||||
* configureLanguageSpecs merge), so tests can assert the merge without spawning a server. */
|
||||
export function _specsForTests(): readonly LanguageSpec[] {
|
||||
return LANGUAGE_SPECS;
|
||||
}
|
||||
|
||||
// The immutable built-in spec list, kept so tests can restore LANGUAGE_SPECS to defaults after a
|
||||
// configureLanguageSpecs call (the merge is forward-only by design — production applies it once).
|
||||
const BUILTIN_LANGUAGE_SPECS: readonly LanguageSpec[] = [
|
||||
{ languageId: "typescript", extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], command: "typescript-language-server", args: ["--stdio"] },
|
||||
{ languageId: "python", extensions: [".py", ".pyi"], command: "pyright-langserver", args: ["--stdio"] },
|
||||
{ languageId: "go", extensions: [".go"], command: "gopls", args: ["serve"] },
|
||||
{ languageId: "rust", extensions: [".rs"], command: "rust-analyzer" },
|
||||
{ languageId: "c", extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], command: "clangd" },
|
||||
];
|
||||
|
||||
/** For tests only: restore the built-in language specs (undo any configureLanguageSpecs merge). */
|
||||
export function _resetSpecsForTests(): void {
|
||||
LANGUAGE_SPECS = BUILTIN_LANGUAGE_SPECS.map((s) => ({ ...s }));
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { _setConfigFilePathForTest, loadStoredConfig, saveStoredConfig } from "./store.js";
|
||||
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "./config.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
|
||||
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveMaxOutputTokens, resolveMaxRetries, resolveRequestTimeoutMs, resolveSubagentTimeoutMs } from "./config.js";
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW_LOCAL, DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_MAX_ITERATIONS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SUBAGENT_TIMEOUT_MS } from "./defaults.js";
|
||||
|
||||
// Isolate the persisted config to a temp directory so the suite never reads or overwrites the
|
||||
// user's real ~/.config/locode/config.json (the previous afterEach { saveStoredConfig({}) } wiped
|
||||
@@ -25,6 +25,9 @@ describe("config resolution", () => {
|
||||
delete process.env.LOCODE_AUTO_COMPACT_THRESHOLD;
|
||||
delete process.env.LOCODE_CONTEXT_WINDOW;
|
||||
delete process.env.LOCODE_MAX_ITERATIONS;
|
||||
delete process.env.LOCODE_MAX_OUTPUT_TOKENS;
|
||||
delete process.env.LOCODE_MAX_OUTPUT_TOKENS;
|
||||
delete process.env.LOCODE_MAX_RETRIES;
|
||||
delete process.env.LOCODE_REQUEST_TIMEOUT_MS;
|
||||
delete process.env.LOCODE_SUBAGENT_TIMEOUT_MS;
|
||||
});
|
||||
@@ -50,14 +53,46 @@ describe("config resolution", () => {
|
||||
expect(resolveAutoCompactThreshold()).toBe(DEFAULT_AUTO_COMPACT_THRESHOLD);
|
||||
});
|
||||
|
||||
it("resolves context window default", () => {
|
||||
expect(resolveContextWindowDefault()).toBe(DEFAULT_CONTEXT_WINDOW);
|
||||
it("resolves context window default — small local model", () => {
|
||||
expect(resolveContextWindowDefault("http://localhost:11434/v1", "llama3.2:latest")).toBe(DEFAULT_CONTEXT_WINDOW_LOCAL);
|
||||
});
|
||||
|
||||
it("resolves context window default — remote cloud API", () => {
|
||||
expect(resolveContextWindowDefault("https://api.openai.com/v1", "gpt-4")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
});
|
||||
|
||||
it("resolves context window default — Ollama's cloud-routed models on a local endpoint", () => {
|
||||
// Regression: glm-5.2:cloud etc. run through the same localhost Ollama daemon as a genuinely
|
||||
// local model, so the base URL alone can't distinguish them — see isSmallLocalModel().
|
||||
expect(resolveContextWindowDefault("http://localhost:11434/v1", "glm-5.2:cloud")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
expect(resolveContextWindowDefault("http://localhost:11434/v1", "qwen3.5:397b-cloud")).toBe(DEFAULT_CONTEXT_WINDOW_CLOUD);
|
||||
});
|
||||
|
||||
it("resolves max iterations default", () => {
|
||||
expect(resolveMaxIterations()).toBe(DEFAULT_MAX_ITERATIONS);
|
||||
});
|
||||
|
||||
it("resolves max output tokens default", () => {
|
||||
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
it("reads max output tokens from env", () => {
|
||||
process.env.LOCODE_MAX_OUTPUT_TOKENS = "16384";
|
||||
expect(resolveMaxOutputTokens()).toBe(16_384);
|
||||
});
|
||||
|
||||
it("reads max output tokens from stored config", () => {
|
||||
saveStoredConfig({ maxOutputTokens: 4096 });
|
||||
expect(resolveMaxOutputTokens()).toBe(4096);
|
||||
});
|
||||
|
||||
it("rejects out-of-range max output tokens", () => {
|
||||
saveStoredConfig({ maxOutputTokens: 100 });
|
||||
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
process.env.LOCODE_MAX_OUTPUT_TOKENS = "5000000";
|
||||
expect(resolveMaxOutputTokens()).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||
});
|
||||
|
||||
it("resolves request timeout default", () => {
|
||||
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
@@ -101,4 +136,25 @@ describe("config resolution", () => {
|
||||
saveStoredConfig({ subagentTimeoutMs: 500 }); // stored below floor
|
||||
expect(resolveSubagentTimeoutMs()).toBe(DEFAULT_SUBAGENT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("resolves max retries default", () => {
|
||||
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
|
||||
});
|
||||
|
||||
it("reads max retries from env", () => {
|
||||
process.env.LOCODE_MAX_RETRIES = "3";
|
||||
expect(resolveMaxRetries()).toBe(3);
|
||||
});
|
||||
|
||||
it("reads max retries from stored config", () => {
|
||||
saveStoredConfig({ maxRetries: 5 });
|
||||
expect(resolveMaxRetries()).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects out-of-range max retries", () => {
|
||||
saveStoredConfig({ maxRetries: 11 });
|
||||
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
|
||||
process.env.LOCODE_MAX_RETRIES = "-1";
|
||||
expect(resolveMaxRetries()).toBe(DEFAULT_MAX_RETRIES);
|
||||
});
|
||||
});
|
||||
+46
-5
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
DEFAULT_AUTO_COMPACT_THRESHOLD,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
DEFAULT_CONTEXT_WINDOW_LOCAL,
|
||||
DEFAULT_CONTEXT_WINDOW_CLOUD,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
DEFAULT_SUBAGENT_TIMEOUT_MS,
|
||||
KNOWN_BACKENDS,
|
||||
type BackendName,
|
||||
isSmallLocalModel,
|
||||
} from "./defaults.js";
|
||||
import { loadStoredConfig } from "./store.js";
|
||||
|
||||
@@ -47,13 +51,28 @@ export function resolveModel(cliModel?: string): string | undefined {
|
||||
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
|
||||
}
|
||||
|
||||
/** The fallback context window size to use when it can't be auto-detected from the backend. */
|
||||
export function resolveContextWindowDefault(): number {
|
||||
/** The fallback context window size to use when it can't be auto-detected from the backend. Picks a
|
||||
* small-local or cloud-scale default based on isSmallLocalModel() — the backend host alone isn't
|
||||
* enough, since Ollama's cloud-routed models (e.g. "glm-5.2:cloud") share a local host with
|
||||
* genuinely local ones. */
|
||||
export function resolveContextWindowDefault(baseURL: string, model: string): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW);
|
||||
if (Number.isFinite(envValue) && envValue > 0) return envValue;
|
||||
if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow;
|
||||
return DEFAULT_CONTEXT_WINDOW;
|
||||
return isSmallLocalModel(baseURL, model) ? DEFAULT_CONTEXT_WINDOW_LOCAL : DEFAULT_CONTEXT_WINDOW_CLOUD;
|
||||
}
|
||||
|
||||
/** Ceiling on a single response's max_tokens (see DEFAULT_MAX_OUTPUT_TOKENS), independent of the
|
||||
* context window. Bounded to 256–1,000,000 to reject pathological values. */
|
||||
export function resolveMaxOutputTokens(): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_MAX_OUTPUT_TOKENS);
|
||||
if (Number.isFinite(envValue) && envValue >= 256 && envValue <= 1_000_000) return envValue;
|
||||
if (typeof stored.maxOutputTokens === "number" && stored.maxOutputTokens >= 256 && stored.maxOutputTokens <= 1_000_000) {
|
||||
return stored.maxOutputTokens;
|
||||
}
|
||||
return DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
/** Max tool calls allowed per turn before locode gives up. */
|
||||
@@ -88,8 +107,22 @@ export function resolveAutoCompactThreshold(): number {
|
||||
return DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Max retry attempts the OpenAI SDK makes on transient failures (connection errors, 429, 5xx)
|
||||
* with exponential backoff. Bounded to 0–10 to reject pathological values. 0 = fail immediately,
|
||||
* matching locode's old behavior of never retrying (a slow local backend usually means the model
|
||||
* is genuinely stuck, not a transient blip — but some setups have occasional connection drops). */
|
||||
export function resolveMaxRetries(): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_MAX_RETRIES);
|
||||
if (Number.isFinite(envValue) && envValue >= 0 && envValue <= 10) return envValue;
|
||||
if (typeof stored.maxRetries === "number" && stored.maxRetries >= 0 && stored.maxRetries <= 10) {
|
||||
return stored.maxRetries;
|
||||
}
|
||||
return DEFAULT_MAX_RETRIES;
|
||||
}
|
||||
|
||||
/** Milliseconds to wait on a single chat completion request before giving up (see backend/client.ts
|
||||
* for why locode doesn't retry on top of this). Bounded to 10s–30min to reject pathological values. */
|
||||
* for why locode defaults to no retries). Bounded to 10s–30min to reject pathological values. */
|
||||
export function resolveRequestTimeoutMs(): number {
|
||||
const stored = loadStoredConfig();
|
||||
const envValue = Number(process.env.LOCODE_REQUEST_TIMEOUT_MS);
|
||||
@@ -99,3 +132,11 @@ export function resolveRequestTimeoutMs(): number {
|
||||
}
|
||||
return DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** User-configured LSP server overrides/additions (see StoredConfig.lspServers). An empty object
|
||||
* means "use the built-in language→server mappings only". Validated loosely: entries without a
|
||||
* command are dropped by configureLanguageSpecs, so we just pass them through. */
|
||||
export function resolveLspServers(): Record<string, { command: string; args?: string[]; extensions?: string[] }> {
|
||||
const stored = loadStoredConfig();
|
||||
return stored.lspServers ?? {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTEXT_WINDOW_CLOUD, DEFAULT_CONTEXT_WINDOW_LOCAL, isCloudRoutedModelName, isLocalBackendURL, isSmallLocalModel } from "./defaults.js";
|
||||
|
||||
describe("isLocalBackendURL", () => {
|
||||
it("recognizes localhost, 127.0.0.1, and ::1", () => {
|
||||
expect(isLocalBackendURL("http://localhost:11434/v1")).toBe(true);
|
||||
expect(isLocalBackendURL("http://127.0.0.1:1234/v1")).toBe(true);
|
||||
expect(isLocalBackendURL("http://[::1]:11434/v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a remote host", () => {
|
||||
expect(isLocalBackendURL("https://api.openai.com/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an unparseable URL instead of throwing", () => {
|
||||
expect(isLocalBackendURL("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCloudRoutedModelName", () => {
|
||||
it("recognizes Ollama's cloud tag conventions", () => {
|
||||
expect(isCloudRoutedModelName("glm-5.2:cloud")).toBe(true);
|
||||
expect(isCloudRoutedModelName("qwen3.5:397b-cloud")).toBe(true);
|
||||
expect(isCloudRoutedModelName("gpt-oss:120b-cloud")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a genuinely local model", () => {
|
||||
expect(isCloudRoutedModelName("llama3.2:latest")).toBe(false);
|
||||
expect(isCloudRoutedModelName("gemma3:4b")).toBe(false);
|
||||
expect(isCloudRoutedModelName("phi4:14b")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not false-positive on 'cloud' appearing outside the tag segment", () => {
|
||||
expect(isCloudRoutedModelName("cloudmodel:latest")).toBe(false);
|
||||
expect(isCloudRoutedModelName("cloud")).toBe(false); // no colon at all
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSmallLocalModel", () => {
|
||||
it("is true for a genuinely local model on a local endpoint", () => {
|
||||
expect(isSmallLocalModel("http://localhost:11434/v1", "llama3.2:latest")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a cloud-routed model even on a local endpoint", () => {
|
||||
expect(isSmallLocalModel("http://localhost:11434/v1", "glm-5.2:cloud")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a remote backend regardless of model name", () => {
|
||||
expect(isSmallLocalModel("https://api.openai.com/v1", "gpt-4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_CONTEXT_WINDOW_LOCAL / CLOUD", () => {
|
||||
it("LOCAL is the small-local fallback (8 192)", () => {
|
||||
expect(DEFAULT_CONTEXT_WINDOW_LOCAL).toBe(8192);
|
||||
});
|
||||
|
||||
it("CLOUD is the cloud/large fallback (131 072)", () => {
|
||||
expect(DEFAULT_CONTEXT_WINDOW_CLOUD).toBe(131072);
|
||||
});
|
||||
});
|
||||
+70
-11
@@ -8,23 +8,82 @@ export const KNOWN_BACKENDS = {
|
||||
|
||||
export type BackendName = keyof typeof KNOWN_BACKENDS;
|
||||
|
||||
/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts)
|
||||
* and the user hasn't configured one — a conservative size common among smaller local models. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
/** Returns true if the given base URL looks like a local backend (Ollama or LM Studio on localhost).
|
||||
* On its own this is NOT enough to tell a small local model from a large one — see
|
||||
* isSmallLocalModel() below, which is what callers should actually use. */
|
||||
export function isLocalBackendURL(baseURL: string): boolean {
|
||||
try {
|
||||
const url = new URL(baseURL);
|
||||
// WHATWG URL keeps the brackets on a literal IPv6 host in .hostname (e.g. "[::1]", not "::1") —
|
||||
// https://url.spec.whatwg.org/#concept-host-serializer.
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Max tool calls per turn before locode gives up rather than looping forever. 50 gives real
|
||||
* multi-file tasks room to breathe (local models often issue one tool call per turn, so a
|
||||
* multi-file edit + verify sequence can easily run past 25); still bounded so a genuinely stuck
|
||||
* model fails fast, and hitting the cap is a soft pause, not a failure (see MaxIterationsError). */
|
||||
export const DEFAULT_MAX_ITERATIONS = 50;
|
||||
/** Ollama's cloud-hosted models are proxied through the same local daemon as a genuinely local
|
||||
* model — same base URL, same host — so isLocalBackendURL() alone can't tell them apart. Ollama
|
||||
* names them with a "cloud" tag segment instead, e.g. "glm-5.2:cloud" or "qwen3.5:397b-cloud". */
|
||||
export function isCloudRoutedModelName(model: string): boolean {
|
||||
const tag = model.split(":")[1] ?? "";
|
||||
return tag === "cloud" || tag.endsWith("-cloud");
|
||||
}
|
||||
|
||||
/** Whether locode should treat this model as a small/less-capable local model — extra system-prompt
|
||||
* guidance about unreliable tool calls and limited output, plus a conservative context-window
|
||||
* fallback — rather than a large, reliable one. True only when the backend is local AND the model
|
||||
* isn't one of Ollama's cloud-routed models under that same local endpoint; a backend hosted
|
||||
* elsewhere (a real remote/cloud API) is never treated as "small local" regardless of model name. */
|
||||
export function isSmallLocalModel(baseURL: string, model: string): boolean {
|
||||
return isLocalBackendURL(baseURL) && !isCloudRoutedModelName(model);
|
||||
}
|
||||
|
||||
/** Fallback context window when auto-detection fails and the user hasn't configured one.
|
||||
* Small local models (see isSmallLocalModel()) typically have 8k–32k context, so 8192 is a safe
|
||||
* conservative default. Everything else (cloud APIs, and Ollama's own cloud-routed models) typically
|
||||
* has 128k–1M+ context, so 131072 (128K) avoids severely underutilizing them. */
|
||||
export const DEFAULT_CONTEXT_WINDOW_LOCAL = 8192;
|
||||
export const DEFAULT_CONTEXT_WINDOW_CLOUD = 131072;
|
||||
|
||||
/** Ceiling on a single response's `max_tokens`, independent of the model's context window. Most
|
||||
* backends cap how much a single completion can generate well below the total context window they
|
||||
* advertise (e.g. Ollama's glm-5.1:cloud / glm-5.2:cloud report a 1,000,000-token context window
|
||||
* but error on a request above their real 131,072-token output cap: "max_tokens (500000) exceeds
|
||||
* model's maximum output tokens (131072)") — resolveMaxTokens (agent/loop.ts) used to request up to
|
||||
* the whole remaining window, which such backends rejected outright as a context/length error even
|
||||
* on the very first turn. 131072 (128K) matches that verified real ceiling exactly, so it's safe to
|
||||
* use as the default without erroring on the very first turn — going straight to a backend's real
|
||||
* cap only became reasonable once detectRepetitionLoop (agent/loop.ts) existed to abort a model
|
||||
* that gets stuck generating instead of relying on this value alone as the safety valve. Raise it
|
||||
* further via `locode config set maxOutputTokens` for backends known to allow more; lower it for
|
||||
* ones with a smaller real ceiling. */
|
||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 131_072;
|
||||
|
||||
/** Max model requests per turn before locode pauses rather than looping forever. Each iteration
|
||||
* is one model generation request (one tool-call round-trip), and local models commonly issue a
|
||||
* single tool call per request — so a real multi-file task (read several files, edit each, grep
|
||||
* to verify, re-read) easily needs 40–60 requests. 50 was too tight and caused frequent
|
||||
* "Paused after 50 steps" soft-stops on legitimate work; 100 still caused frequent pauses on
|
||||
* larger tasks. 300 gives real tasks ample room to finish while still bounding a genuinely stuck
|
||||
* model. Hitting the cap is a soft pause, not a failure (the work so far is intact — send another
|
||||
* message to resume). Configurable via `maxIterations`, e.g. `locode config set maxIterations 500`
|
||||
* for very large batch jobs. */
|
||||
export const DEFAULT_MAX_ITERATIONS = 300;
|
||||
|
||||
/** Fraction of the context window at which locode automatically summarizes the conversation.
|
||||
* User-configurable via `locode config set autoCompactThreshold`. */
|
||||
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 0.85;
|
||||
|
||||
/** How long to wait on a single chat completion request before giving up (no retries — see
|
||||
* backend/client.ts). Raise this via `requestTimeoutMs` if your backend queues requests behind a
|
||||
* concurrency limit (e.g. Ollama's `OLLAMA_NUM_PARALLEL`) rather than serving them immediately. */
|
||||
/** How long to wait on a single chat completion request before giving up. The OpenAI SDK retries
|
||||
* transient failures (connection errors, 429, 5xx) up to `maxRetries` times with exponential
|
||||
* backoff before surfacing the error; set to 0 to fail immediately like older locode versions.
|
||||
* Raise this via `maxRetries` if your backend has occasional transient blips. See backend/client.ts. */
|
||||
export const DEFAULT_MAX_RETRIES = 0;
|
||||
|
||||
/** How long to wait on a single chat completion request before giving up. Raise this via
|
||||
* `requestTimeoutMs` if your backend queues requests behind a concurrency limit (e.g. Ollama's
|
||||
* `OLLAMA_NUM_PARALLEL`) rather than serving them immediately. */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Wall-clock budget for a single sub-agent turn. Sub-agents make their own sequence of model
|
||||
|
||||
+31
-3
@@ -16,6 +16,18 @@ export interface StoredConfig {
|
||||
requestTimeoutMs?: number;
|
||||
/** Milliseconds of wall-clock budget for a single sub-agent turn. */
|
||||
subagentTimeoutMs?: number;
|
||||
/** Ceiling on a single response's max_tokens, independent of contextWindow. */
|
||||
maxOutputTokens?: number;
|
||||
/** Max retry attempts the OpenAI SDK makes on transient failures (connection errors, 429, 5xx)
|
||||
* before surfacing the error. 0 = fail immediately (old behavior); the SDK uses exponential
|
||||
* backoff between attempts. */
|
||||
maxRetries?: number;
|
||||
/** User-defined LSP server overrides/additions, keyed by language id (e.g. "java", "ruby",
|
||||
* "typescript"). Each entry is { command, args?, extensions? }. An entry for a built-in id
|
||||
* overrides its command/args; an entry with `extensions` also rewrites which file extensions
|
||||
* route to that language. Entries for new ids add support for languages locode doesn't ship a
|
||||
* server for. See `locode config set lspServers` (JSON value). */
|
||||
lspServers?: Record<string, { command: string; args?: string[]; extensions?: string[] }>;
|
||||
}
|
||||
|
||||
const paths = envPaths("locode", { suffix: "" });
|
||||
@@ -32,20 +44,36 @@ export function configFilePath(): string {
|
||||
/** @internal For tests only — redirect config persistence to `path` (pass undefined to reset). */
|
||||
export function _setConfigFilePathForTest(p: string | undefined): void {
|
||||
configFileOverride = p;
|
||||
invalidateConfigCache();
|
||||
}
|
||||
|
||||
// In-memory cache so every resolve*() call in the same process doesn't re-read and re-parse
|
||||
// the same small JSON file (8–10 calls during startup alone). Invalidated by saveStoredConfig
|
||||
// and by tests that change the config path.
|
||||
let cachedConfig: StoredConfig | undefined;
|
||||
|
||||
export function loadStoredConfig(): StoredConfig {
|
||||
if (cachedConfig !== undefined) return cachedConfig;
|
||||
const file = configFilePath();
|
||||
if (!existsSync(file)) return {};
|
||||
if (!existsSync(file)) { cachedConfig = {}; return cachedConfig; }
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf-8")) as StoredConfig;
|
||||
cachedConfig = JSON.parse(readFileSync(file, "utf-8")) as StoredConfig;
|
||||
return cachedConfig;
|
||||
} catch {
|
||||
return {};
|
||||
cachedConfig = {};
|
||||
return cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate the in-memory cache — called by saveStoredConfig and by tests that change
|
||||
* the config file path. */
|
||||
export function invalidateConfigCache(): void {
|
||||
cachedConfig = undefined;
|
||||
}
|
||||
|
||||
export function saveStoredConfig(cfg: StoredConfig): void {
|
||||
const file = configFilePath();
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(cfg, null, 2));
|
||||
cachedConfig = cfg;
|
||||
}
|
||||
@@ -91,11 +91,22 @@ describe("runHooksForEvent", () => {
|
||||
expect(result.additionalContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns for unsupported prompt hooks", async () => {
|
||||
it("injects a prompt hook's message as additional context", async () => {
|
||||
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
|
||||
SessionStart: [{ hooks: [{ type: "prompt", message: "ok?" } as any] }],
|
||||
SessionStart: [{ hooks: [{ type: "prompt", message: "Remember to check the changelog." }] }],
|
||||
});
|
||||
const result = await runHooksForEvent("SessionStart", ctx, {});
|
||||
expect(result.warnings).toContain("1 prompt hook(s) skipped (not yet implemented).");
|
||||
expect(result.additionalContext).toBe("Remember to check the changelog.");
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("combines a prompt hook's message with a command hook's stdout", async () => {
|
||||
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
|
||||
SessionStart: [{ hooks: [{ type: "prompt", message: "prompt message" }, { type: "command", command: "echo cmd" }] }],
|
||||
});
|
||||
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "cmd output", stderr: "", timedOut: false });
|
||||
const result = await runHooksForEvent("SessionStart", ctx, {});
|
||||
expect(result.additionalContext).toContain("prompt message");
|
||||
expect(result.additionalContext).toContain("cmd output");
|
||||
});
|
||||
});
|
||||
|
||||
+17
-11
@@ -2,7 +2,7 @@ import { execa } from "execa";
|
||||
import path from "node:path";
|
||||
import { loadMergedHooks } from "./config.js";
|
||||
import { matcherMatches } from "./matcher.js";
|
||||
import type { Hook, HookCommand, HookEventName, HookHttp } from "./types.js";
|
||||
import type { Hook, HookCommand, HookEventName, HookHttp, HookPrompt } from "./types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
|
||||
@@ -37,6 +37,20 @@ function isHttpHook(hook: Hook): hook is HookHttp {
|
||||
return hook.type === "http";
|
||||
}
|
||||
|
||||
function isPromptHook(hook: Hook): hook is HookPrompt {
|
||||
return hook.type === "prompt";
|
||||
}
|
||||
|
||||
/** A prompt hook has no process/response to run — it's just a static message that always
|
||||
* "succeeds" and folds into additionalContext the same way a command/http hook's plain-text
|
||||
* stdout does (see the outcome-handling loop in runHooksForEvent). Wrapped in a resolved promise
|
||||
* so it can share the same Promise.all as the other hook kinds. */
|
||||
async function runPromptHook(
|
||||
hook: HookPrompt,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean; json?: unknown; warning?: string }> {
|
||||
return { exitCode: 0, stdout: hook.message, stderr: "", timedOut: false };
|
||||
}
|
||||
|
||||
function parseOutput(output: string, schema?: "json"): { text?: string; json?: unknown; warning?: string } {
|
||||
if (!schema) return { text: output };
|
||||
if (schema !== "json") return { text: output };
|
||||
@@ -195,26 +209,18 @@ export async function runHooksForEvent(
|
||||
.flatMap((entry) => entry.hooks);
|
||||
if (hooks.length === 0) return EMPTY_RESULT;
|
||||
|
||||
// Prompt hooks are declared in the type system but not implemented in this pass — they need a
|
||||
// blocking UI flow the current runner doesn't have. Skip them with a warning so a config that
|
||||
// includes them doesn't silently do nothing.
|
||||
const skippedPrompts = hooks.filter((h) => h.type === "prompt").length;
|
||||
const runnableHooks = hooks.filter((hook) => hook.type !== "prompt");
|
||||
|
||||
const stdinPayload = JSON.stringify({ hook_event_name: event, session_id: ctx.sessionId, cwd: ctx.cwd, ...payload });
|
||||
|
||||
const outcomes = await Promise.all(
|
||||
runnableHooks.map(async (hook) => {
|
||||
hooks.map(async (hook) => {
|
||||
if (isCommandHook(hook)) return runCommandHook(hook, stdinPayload, ctx);
|
||||
if (isHttpHook(hook)) return runHttpHook(hook, stdinPayload, ctx);
|
||||
if (isPromptHook(hook)) return runPromptHook(hook);
|
||||
return { exitCode: 1, stdout: "", stderr: `Unsupported hook type: ${(hook as Hook).type}`, timedOut: false };
|
||||
}),
|
||||
);
|
||||
|
||||
const result: HookResult = { blocked: false, warnings: [] };
|
||||
if (skippedPrompts > 0) {
|
||||
result.warnings.push(`${skippedPrompts} prompt hook(s) skipped (not yet implemented).`);
|
||||
}
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.warning) {
|
||||
result.warnings.push(outcome.warning);
|
||||
|
||||
+3
-1
@@ -40,8 +40,10 @@ export interface HookHttp extends HookBase {
|
||||
|
||||
export interface HookPrompt extends HookBase {
|
||||
type: "prompt";
|
||||
/** Injected verbatim as additional context, the same way a command/http hook's stdout is —
|
||||
* see runner.ts. Always "succeeds" (there's no process/response to fail); a prompt hook can't
|
||||
* block an event the way a command hook's exit code 2 can. */
|
||||
message: string;
|
||||
/** Not yet implemented — prompt hooks require a UI blocking flow the current runner doesn't support. */
|
||||
}
|
||||
|
||||
export type Hook = HookCommand | HookHttp | HookPrompt;
|
||||
|
||||
+48
-24
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
|
||||
import pkg from "../../package.json" with { type: "json" };
|
||||
|
||||
export interface McpTextContentBlock {
|
||||
type: "text";
|
||||
@@ -54,30 +55,53 @@ export interface ConnectedMcpServer {
|
||||
tools: McpToolInfo[];
|
||||
}
|
||||
|
||||
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
|
||||
const transport: Transport = isHttpServerConfig(config)
|
||||
? new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
})
|
||||
: new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
// Default is "inherit", which would leak the child's stderr straight into the terminal
|
||||
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
|
||||
stderr: "pipe",
|
||||
});
|
||||
/** Max connection attempts for an MCP server that fails transiently (process slow to start,
|
||||
* HTTP 503, etc.). A stdio server whose command genuinely doesn't exist fails immediately every
|
||||
* time, so retries only help the transient case — kept small to avoid stalling startup. */
|
||||
const MCP_CONNECT_MAX_ATTEMPTS = 3;
|
||||
const MCP_CONNECT_BASE_DELAY_MS = 500;
|
||||
|
||||
const client = new Client({ name: "locode", version: "0.3.1" });
|
||||
await client.connect(transport);
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
return { name, client, transport, tools: tools as McpToolInfo[] };
|
||||
} catch (err) {
|
||||
// listTools failed (server connected but never responded to the listing) — close the
|
||||
// transport so the stdio subprocess / HTTP connection isn't orphaned. manager.ts catches
|
||||
// the rejection as an error status, but without this the child process keeps running.
|
||||
await transport.close().catch(() => {});
|
||||
throw err;
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= MCP_CONNECT_MAX_ATTEMPTS; attempt++) {
|
||||
const transport: Transport = isHttpServerConfig(config)
|
||||
? new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
})
|
||||
: new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
// Default is "inherit", which would leak the child's stderr straight into the terminal
|
||||
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const client = new Client({ name: "locode", version: pkg.version });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const { tools } = await client.listTools();
|
||||
return { name, client, transport, tools: tools as McpToolInfo[] };
|
||||
} catch (err) {
|
||||
// listTools failed or connect failed — close the transport so the stdio subprocess / HTTP
|
||||
// connection isn't orphaned. manager.ts catches the rejection as an error status, but
|
||||
// without this the child process keeps running.
|
||||
await transport.close().catch(() => {});
|
||||
lastErr = err;
|
||||
// Retry with exponential backoff for transient failures; the last attempt's error is what
|
||||
// the caller sees. A genuinely broken config (missing binary, bad URL) fails fast every time,
|
||||
// so the retries just add a small delay — acceptable for the rare transient-startup case.
|
||||
if (attempt < MCP_CONNECT_MAX_ATTEMPTS) {
|
||||
await sleep(MCP_CONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
@@ -76,3 +76,12 @@ export async function disconnectAllMcpServers(): Promise<void> {
|
||||
await Promise.allSettled(connections.map((c) => c.transport.close()));
|
||||
connections = [];
|
||||
}
|
||||
|
||||
/** Re-connects to every configured MCP server (e.g. after the user edits `.mcp.json` or restarts a
|
||||
* server process), swapping in the new connections and returning the fresh tool list so the caller
|
||||
* can rebuild the session's toolset. Equivalent to a fresh `connectConfiguredMcpServers` call,
|
||||
* exposed separately so callers can name the intent ("reconnect") without implying the first-run
|
||||
* setup path. */
|
||||
export async function reconnectMcpServers(cwd: string): Promise<ToolDef[]> {
|
||||
return connectConfiguredMcpServers(cwd);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ export class PermissionManager {
|
||||
|
||||
/** Check whether a mutating tool should be auto-approved (no confirmation needed). */
|
||||
isAutoApproved(toolName: string): boolean {
|
||||
// auto-accept only covers the same file-edit tools as auto-edit, not arbitrary mutating tools
|
||||
// such as bash or git_commit. This prevents a user who intended "approve edits" from silently
|
||||
// approving every dangerous operation.
|
||||
if (this.mode === "auto-accept" && AUTO_EDIT_TOOLS.has(toolName)) return true;
|
||||
// auto-accept approves EVERY mutating tool (bash, git_commit, write_file, edit_file, ...) —
|
||||
// the "I trust everything, don't ask" mode. auto-edit is the narrower "approve file edits only"
|
||||
// mode, auto-approving just the file-edit tools so a user can batch-edit without approving each
|
||||
// one but still gate dangerous shell/git operations. default falls back to the session-allowed
|
||||
// list (tools the user approved "for this session" in a prior prompt).
|
||||
if (this.mode === "auto-accept") return true;
|
||||
if (this.mode === "auto-edit" && AUTO_EDIT_TOOLS.has(toolName)) return true;
|
||||
// "default" — check session-allowed list
|
||||
return this.allowedForSession.has(toolName);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import { exportSession, sessionToJson, sessionToMarkdown, defaultExportFilename } from "./exportSession.js";
|
||||
|
||||
const meta = { model: "test-model", createdAt: "2026-01-01T00:00:00.000Z" };
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "let me check", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }] },
|
||||
{ role: "tool", tool_call_id: "call_1", content: "file contents" },
|
||||
{ role: "assistant", content: "done" },
|
||||
];
|
||||
|
||||
describe("sessionToMarkdown (full transcript)", () => {
|
||||
it("includes user text, assistant text, tool calls, and tool results", () => {
|
||||
const md = sessionToMarkdown(messages, meta);
|
||||
expect(md).toContain("### You");
|
||||
expect(md).toContain("hello");
|
||||
expect(md).toContain("let me check");
|
||||
expect(md).toContain("#### Tool calls");
|
||||
expect(md).toContain('"name": "read_file"');
|
||||
expect(md).toContain("#### Tool result");
|
||||
expect(md).toContain("file contents");
|
||||
expect(md).toContain("done");
|
||||
});
|
||||
|
||||
it("does not drop a tool-only assistant turn (no text)", () => {
|
||||
const md = sessionToMarkdown(
|
||||
[{ role: "assistant", content: null, tool_calls: [{ id: "c", type: "function", function: { name: "grep", arguments: "{}" } }] } as ChatCompletionMessageParam],
|
||||
meta,
|
||||
);
|
||||
expect(md).toContain("#### Tool calls");
|
||||
expect(md).toContain('"name": "grep"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionToJson", () => {
|
||||
it("produces a JSON object with meta, exportedAt, and the verbatim messages", () => {
|
||||
const json = sessionToJson(messages, meta);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.model).toBe("test-model");
|
||||
expect(parsed.exportedAt).toBeTruthy();
|
||||
expect(parsed.messages).toHaveLength(4);
|
||||
expect(parsed.messages[1].tool_calls[0].function.name).toBe("read_file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultExportFilename", () => {
|
||||
it("defaults to a .md extension", () => {
|
||||
expect(defaultExportFilename()).toMatch(/\.md$/);
|
||||
});
|
||||
it("uses .json for the json format", () => {
|
||||
expect(defaultExportFilename("json")).toMatch(/\.json$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportSession", () => {
|
||||
let cwd: string;
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-export-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes a markdown file by default", async () => {
|
||||
const resolved = await exportSession(messages, meta, cwd, "out.md");
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
expect(content).toContain("# locode conversation");
|
||||
expect(content).toContain("hello");
|
||||
});
|
||||
|
||||
it("writes a JSON file when format is json", async () => {
|
||||
const resolved = await exportSession(messages, meta, cwd, "out.json", "json");
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.messages).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("auto-generates a filename with the right extension when none given", async () => {
|
||||
const resolved = await exportSession(messages, meta, cwd, undefined, "json");
|
||||
expect(resolved).toMatch(/\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,41 @@ import path from "node:path";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
|
||||
|
||||
/** Mirrors the filtering used when replaying a resumed session (see App.tsx initSessionFromRecord):
|
||||
* only plain user/assistant text turns are human-readable — raw tool-call/tool-result payloads
|
||||
* and fallback-mode `tool_result` blocks are internal bookkeeping, not conversation content. */
|
||||
/** Renders one message as a markdown section for a FULL transcript export — including tool
|
||||
* calls and their results, which the old prose-only export dropped. A tool-call assistant turn
|
||||
* lists each call as a fenced JSON block; a tool-result message is rendered as a fenced result.
|
||||
* Multimodal user content (text + image parts) is reduced to its text parts plus an
|
||||
* `[image attached]` placeholder. Returns null only for genuinely empty turns. */
|
||||
function messageSection(m: ChatCompletionMessageParam): string | null {
|
||||
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
|
||||
return `### You\n\n${m.content}`;
|
||||
if (m.role === "user") {
|
||||
if (typeof m.content === "string") {
|
||||
if (m.content.startsWith("```tool_result")) {
|
||||
// A fallback-mode tool result block — render it verbatim under a Tool result heading.
|
||||
return `#### Tool result\n\n${m.content}`;
|
||||
}
|
||||
return `### You\n\n${m.content}`;
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
const parts = m.content.map((p) => (p.type === "text" ? p.text : "[image attached]")).join("\n");
|
||||
return parts.trim() ? `### You\n\n${parts}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (m.role === "assistant" && typeof m.content === "string" && m.content) {
|
||||
return `### Assistant\n\n${m.content}`;
|
||||
if (m.role === "assistant") {
|
||||
const text = typeof m.content === "string" ? m.content : "";
|
||||
const calls = (m as { tool_calls?: { id: string; function: { name: string; arguments: string } }[] }).tool_calls;
|
||||
const parts: string[] = [];
|
||||
if (text.trim()) parts.push(`### Assistant\n\n${text}`);
|
||||
if (calls && calls.length) {
|
||||
const block = calls.map((c) => `{"name": "${c.function.name}", "arguments": ${c.function.arguments}}`).join("\n");
|
||||
parts.push(`#### Tool calls\n\n` + "```json\n" + block + "\n```");
|
||||
}
|
||||
return parts.length ? parts.join("\n\n") : null;
|
||||
}
|
||||
if (m.role === "tool") {
|
||||
const tm = m as { content?: string; tool_call_id?: string };
|
||||
const body = typeof tm.content === "string" ? tm.content : JSON.stringify(tm.content);
|
||||
return `#### Tool result${tm.tool_call_id ? ` (${tm.tool_call_id})` : ""}\n\n` + "```\n" + body + "\n```";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +47,8 @@ export interface ExportMeta {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ExportFormat = "markdown" | "json";
|
||||
|
||||
export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
|
||||
const header = [
|
||||
"# locode conversation",
|
||||
@@ -33,22 +61,35 @@ export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta:
|
||||
return [header, ...sections].join("\n\n");
|
||||
}
|
||||
|
||||
export function defaultExportFilename(): string {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `locode-export-${stamp}.md`;
|
||||
/** A JSON export is the full record (messages verbatim + metadata), suitable for cross-machine
|
||||
* replay/sharing or feeding into another tool. The markdown export is for humans. */
|
||||
export function sessionToJson(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
|
||||
return JSON.stringify({ ...meta, exportedAt: new Date().toISOString(), messages }, null, 2);
|
||||
}
|
||||
|
||||
/** Writes the conversation to a markdown file and returns the resolved absolute path.
|
||||
* `target` may be a bare filename, a relative path, or an absolute path; a bare directory
|
||||
* (or nothing at all) falls back to an auto-generated filename inside `cwd`. Written atomically
|
||||
export function defaultExportFilename(format: ExportFormat = "markdown"): string {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `locode-export-${stamp}.${format === "json" ? "json" : "md"}`;
|
||||
}
|
||||
|
||||
/** Writes the conversation to a file and returns the resolved absolute path. `target` may be a bare
|
||||
* filename, a relative path, or an absolute path; a bare directory (or nothing at all) falls back
|
||||
* to an auto-generated filename inside `cwd`. `format` selects a human markdown transcript
|
||||
* (default, now including tool calls/results) or a machine-readable JSON dump. Written atomically
|
||||
* (temp file + rename), matching sessionStore's saves, so a crash mid-export can't leave a
|
||||
* truncated file. */
|
||||
export async function exportSession(messages: ChatCompletionMessageParam[], meta: ExportMeta, cwd: string, target?: string): Promise<string> {
|
||||
const filename = target?.trim() || defaultExportFilename();
|
||||
export async function exportSession(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
meta: ExportMeta,
|
||||
cwd: string,
|
||||
target?: string,
|
||||
format: ExportFormat = "markdown",
|
||||
): Promise<string> {
|
||||
const filename = target?.trim() || defaultExportFilename(format);
|
||||
let resolved = path.isAbsolute(filename) ? filename : path.resolve(cwd, filename);
|
||||
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
|
||||
resolved = path.join(resolved, defaultExportFilename());
|
||||
resolved = path.join(resolved, defaultExportFilename(format));
|
||||
}
|
||||
await writeFileAtomic(resolved, sessionToMarkdown(messages, meta));
|
||||
await writeFileAtomic(resolved, format === "json" ? sessionToJson(messages, meta) : sessionToMarkdown(messages, meta));
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import { buildReplayHistory } from "./replayHistory.js";
|
||||
|
||||
describe("buildReplayHistory", () => {
|
||||
it("replays plain user/assistant text turns", () => {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello there" },
|
||||
];
|
||||
expect(buildReplayHistory(messages)).toMatchObject([
|
||||
{ kind: "user", text: "hi" },
|
||||
{ kind: "assistant", text: "hello there" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays native-mode tool calls and results, correlating the result's name via tool_call_id", () => {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "read foo.txt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "call_1", type: "function", function: { name: "read_file", arguments: JSON.stringify({ path: "foo.txt" }) } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: JSON.stringify({ totalLines: 3 }) },
|
||||
{ role: "assistant", content: "It has 3 lines." },
|
||||
];
|
||||
expect(buildReplayHistory(messages)).toMatchObject([
|
||||
{ kind: "user", text: "read foo.txt" },
|
||||
{ kind: "tool_call", label: "Read(foo.txt)" },
|
||||
{ kind: "tool_result", summary: "Read 3 lines", isError: false },
|
||||
{ kind: "assistant", text: "It has 3 lines." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks a native-mode tool error result", () => {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "bash", arguments: "{}" } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: JSON.stringify({ error: "command not found" }) },
|
||||
];
|
||||
expect(buildReplayHistory(messages)).toMatchObject([
|
||||
{ kind: "tool_call", label: "Bash()" },
|
||||
{ kind: "tool_result", summary: "command not found", isError: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays fallback-mode tool_call blocks embedded in assistant text", () => {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: 'Let me check.\n```tool_call\n{"name":"read_file","arguments":{"path":"foo.txt"}}\n```',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: '```tool_result\n{"name":"read_file","result":{"totalLines":3}}\n```',
|
||||
},
|
||||
];
|
||||
expect(buildReplayHistory(messages)).toMatchObject([
|
||||
{ kind: "assistant", text: "Let me check." },
|
||||
{ kind: "tool_call", label: "Read(foo.txt)" },
|
||||
{ kind: "tool_result", summary: "Read 3 lines", isError: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the empty assistant text bubble when a native tool call has no accompanying prose", () => {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "grep", arguments: "{}" } }],
|
||||
},
|
||||
];
|
||||
expect(buildReplayHistory(messages)).toMatchObject([{ kind: "tool_call", label: "Grep()" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
||||
import { nextId, type HistoryItem, type NewHistoryItem } from "../ui/ink/types.js";
|
||||
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
||||
|
||||
const TOOL_CALL_BLOCK_RE = /```tool_call\s*[\s\S]*?```/g;
|
||||
const TOOL_RESULT_BLOCK_RE = /^```tool_result\n([\s\S]*?)\n```$/;
|
||||
|
||||
/** A message's `content` can be a plain string or an array of content parts (text/image_url) —
|
||||
* see pushToolResultMessage in agent/loop.ts. Only the text part matters for replay display. */
|
||||
function textContent(content: ChatCompletionMessageParam["content"]): string | null {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const part = content.find((p): p is { type: "text"; text: string } => (p as { type?: string }).type === "text");
|
||||
return part?.text ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): boolean {
|
||||
return !!(result && typeof result === "object" && "error" in (result as object));
|
||||
}
|
||||
|
||||
/** Rebuilds the tool_call/tool_result HistoryItems a resumed session's transcript is otherwise
|
||||
* missing (see App.tsx initSessionFromRecord) — the persisted record (SessionRecord.messages) is
|
||||
* the raw OpenAI-shape history, which carries everything needed (tool name, arguments, result)
|
||||
* even though it was never saved as a pre-rendered display label. Handles both native mode
|
||||
* (assistant `tool_calls` + matching `tool`-role messages, correlated by `tool_call_id`) and
|
||||
* fallback mode (` ```tool_call``` ` blocks embedded in assistant text, ` ```tool_result``` `
|
||||
* blocks embedded in user text — see fallbackParser.ts and pushToolResultMessage). */
|
||||
export function buildReplayHistory(messages: ChatCompletionMessageParam[]): HistoryItem[] {
|
||||
const items: HistoryItem[] = [];
|
||||
// Native mode: `tool` messages only carry a tool_call_id, not the tool's name — remember the
|
||||
// name from the assistant message that made the call so its later result can be labeled.
|
||||
const pendingCallNames = new Map<string, string>();
|
||||
|
||||
const push = (item: NewHistoryItem) => items.push({ id: nextId(), ...item } as HistoryItem);
|
||||
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const text = textContent(m.content);
|
||||
if (text === null) continue;
|
||||
const fallbackResult = TOOL_RESULT_BLOCK_RE.exec(text.trim());
|
||||
if (fallbackResult) {
|
||||
try {
|
||||
const { name, result } = JSON.parse(fallbackResult[1]!) as { name: string; result: unknown };
|
||||
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
|
||||
} catch {
|
||||
// Malformed persisted block (shouldn't happen since we wrote it) — drop rather than
|
||||
// show the raw JSON to the user.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (text) push({ kind: "user", text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const toolCalls = m.tool_calls;
|
||||
if (toolCalls?.length) {
|
||||
const text = textContent(m.content);
|
||||
if (text) push({ kind: "assistant", text });
|
||||
for (const call of toolCalls) {
|
||||
if (call.type !== "function") continue;
|
||||
let args: unknown = {};
|
||||
try {
|
||||
args = JSON.parse(call.function.arguments);
|
||||
} catch {
|
||||
// Leave args as {} — formatCallLabel degrades gracefully for a missing field.
|
||||
}
|
||||
pendingCallNames.set(call.id, call.function.name);
|
||||
push({ kind: "tool_call", label: formatCallLabel(call.function.name, args) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = textContent(m.content);
|
||||
if (!text) continue;
|
||||
const parsed = parseFallbackToolCalls(text);
|
||||
if (parsed.calls.length) {
|
||||
const stripped = text.replace(TOOL_CALL_BLOCK_RE, "").trim();
|
||||
if (stripped) push({ kind: "assistant", text: stripped });
|
||||
for (const call of parsed.calls) {
|
||||
push({ kind: "tool_call", label: formatCallLabel(call.name, call.arguments) });
|
||||
}
|
||||
} else {
|
||||
push({ kind: "assistant", text });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === "tool") {
|
||||
const name = pendingCallNames.get(m.tool_call_id) ?? "unknown";
|
||||
const text = textContent(m.content);
|
||||
let result: unknown = text;
|
||||
if (text) {
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch {
|
||||
result = text;
|
||||
}
|
||||
}
|
||||
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// unlinkSync is mocked (default: pass-through to the real implementation) only so the "can't
|
||||
// actually delete the file" test below can make a single call fail — every other test's calls to
|
||||
// unlinkSync still hit the real filesystem via this same mock.
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return { ...actual, unlinkSync: vi.fn(actual.unlinkSync) };
|
||||
});
|
||||
import envPaths from "env-paths";
|
||||
import {
|
||||
deleteSession,
|
||||
@@ -70,6 +78,32 @@ describe("sessionStore", () => {
|
||||
expect(listSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rebuilds the summary index from disk when no index file exists yet", () => {
|
||||
writeFileSync(path.join(dir, "manual-session.json"), JSON.stringify(makeRecord("manual-session")));
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
expect(listSessions()[0]?.id).toBe("manual-session");
|
||||
});
|
||||
|
||||
it("reports failure (not success) when the file can't actually be deleted", async () => {
|
||||
// Regression: a real unlink failure (e.g. Windows EPERM/EBUSY from a file lock) used to still
|
||||
// return true and drop the entry from the index — reporting success while orphaning the file
|
||||
// on disk with no way to reference it again.
|
||||
await saveSession(makeRecord("locked-session"));
|
||||
vi.mocked(unlinkSync).mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" });
|
||||
});
|
||||
expect(deleteSession("locked-session")).toBe(false);
|
||||
expect(loadSession("locked-session")?.id).toBe("locked-session");
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("self-heals when a session file is deleted outside of deleteSession()", async () => {
|
||||
await saveSession(makeRecord("will-vanish"));
|
||||
expect(listSessions()).toHaveLength(1);
|
||||
unlinkSync(path.join(dir, "will-vanish.json"));
|
||||
expect(listSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deriveTitle extracts the first user message", () => {
|
||||
const title = deriveTitle([
|
||||
{ role: "system", content: "sys" },
|
||||
|
||||
+118
-22
@@ -1,8 +1,9 @@
|
||||
import envPaths from "env-paths";
|
||||
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
import type { ToolCallMode } from "../backend/capabilityProbe.js";
|
||||
import type { TaskStoreSnapshot } from "../tools/task.js";
|
||||
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
|
||||
|
||||
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
|
||||
@@ -16,6 +17,10 @@ export interface SessionRecord {
|
||||
model: string;
|
||||
mode: ToolCallMode;
|
||||
messages: ChatCompletionMessageParam[];
|
||||
/** Tool names the user approved "for this session" — preserved across resume. */
|
||||
allowedTools?: string[];
|
||||
/** Persisted task store snapshot so tasks survive session resume. */
|
||||
tasks?: TaskStoreSnapshot;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
@@ -45,6 +50,92 @@ function filePath(id: string): string {
|
||||
return path.join(dir, `${safeSessionId(id)}.json`);
|
||||
}
|
||||
|
||||
const INDEX_FILENAME = "_index.json";
|
||||
|
||||
function indexFilePath(): string {
|
||||
return path.join(dir, INDEX_FILENAME);
|
||||
}
|
||||
|
||||
function summarize(record: SessionRecord): SessionSummary {
|
||||
return {
|
||||
id: record.id,
|
||||
updatedAt: record.updatedAt,
|
||||
title: deriveTitle(record.messages),
|
||||
model: record.model,
|
||||
baseURL: record.baseURL,
|
||||
messageCount: record.messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the on-disk summary index and reconciles it against the actual session files, so a stale or
|
||||
* missing index self-heals instead of ever going wrong: entries whose file was deleted (by this or
|
||||
* another locode process) are dropped, and files present on disk but missing from the index (a fresh
|
||||
* install, a crash before the last index write, another process's save racing this one) are parsed
|
||||
* individually. This keeps the common case to O(session count) stat calls instead of O(total
|
||||
* transcript bytes) — listSessions() used to JSON.parse every saved session in full just to read 6
|
||||
* summary fields off each one. */
|
||||
function readIndex(): Map<string, SessionSummary> {
|
||||
let index = new Map<string, SessionSummary>();
|
||||
if (existsSync(indexFilePath())) {
|
||||
try {
|
||||
const entries = JSON.parse(readFileSync(indexFilePath(), "utf-8")) as SessionSummary[];
|
||||
index = new Map(entries.map((e) => [e.id, e]));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[sessionStore] failed to parse index file, rebuilding:", err);
|
||||
index = new Map();
|
||||
}
|
||||
}
|
||||
for (const id of index.keys()) {
|
||||
if (!existsSync(filePath(id))) index.delete(id);
|
||||
}
|
||||
const known = new Set([...index.keys()].map((id) => safeSessionId(id)));
|
||||
if (existsSync(dir)) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!entry.endsWith(".json") || entry === INDEX_FILENAME) continue;
|
||||
const stem = entry.slice(0, -".json".length);
|
||||
if (known.has(stem)) continue;
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
|
||||
index.set(record.id, summarize(record));
|
||||
} catch (err) {
|
||||
// Skip corrupt/partial session files — but log so disk issues aren't silent.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] skipping corrupt session file ${entry}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Best-effort atomic write of the index. A failed write just means the next readIndex() call
|
||||
* re-parses whatever files it doesn't recognize yet — never incorrect data, only a missed
|
||||
* optimization. */
|
||||
function writeIndex(index: Map<string, SessionSummary>): void {
|
||||
const tmp = `${indexFilePath()}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify([...index.values()]));
|
||||
renameSync(tmp, indexFilePath());
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
// tmp may not have been created if the write itself failed
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[sessionStore] failed to write session index:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates one entry in the persisted index. Two saves for different sessions racing this can lose
|
||||
* one's index write, but never lose data: readIndex() picks up any on-disk session file it doesn't
|
||||
* recognize, so the loser just costs the next listSessions() call one extra parse. */
|
||||
function updateIndexEntry(record: SessionRecord): void {
|
||||
const index = readIndex();
|
||||
index.set(record.id, summarize(record));
|
||||
writeIndex(index);
|
||||
}
|
||||
|
||||
export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
|
||||
const first = messages.find((m) => m.role === "user");
|
||||
const text = first && typeof first.content === "string" ? first.content.trim() : "";
|
||||
@@ -63,7 +154,8 @@ const saveQueues = new Map<string, Promise<void>>();
|
||||
* otherwise load as "no session found" (see loadSession, which treats an unparseable file as absent). */
|
||||
export async function saveSession(record: SessionRecord): Promise<void> {
|
||||
const file = filePath(record.id);
|
||||
const write = () => writeFileAtomic(file, JSON.stringify(record, null, 2));
|
||||
const write = () =>
|
||||
writeFileAtomic(file, JSON.stringify(record, null, 2)).then(() => updateIndexEntry(record));
|
||||
// Run whether or not the previous save rejected, so one failure can't stall the chain.
|
||||
const prev = saveQueues.get(record.id);
|
||||
const next = (prev ?? Promise.resolve()).then(write, write);
|
||||
@@ -85,31 +177,19 @@ export function loadSession(id: string): SessionRecord | undefined {
|
||||
if (!existsSync(file)) return undefined;
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Corrupt or unreadable session file — treat as absent, but log so disk issues
|
||||
// aren't completely silent. Callers can't distinguish "no file" from "corrupt file",
|
||||
// but at least the log preserves the reason.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] failed to load session ${id}, treating as absent:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSessions(): SessionSummary[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const summaries: SessionSummary[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
|
||||
summaries.push({
|
||||
id: record.id,
|
||||
updatedAt: record.updatedAt,
|
||||
title: deriveTitle(record.messages),
|
||||
model: record.model,
|
||||
baseURL: record.baseURL,
|
||||
messageCount: record.messages.length,
|
||||
});
|
||||
} catch {
|
||||
// Skip corrupt/partial session files
|
||||
}
|
||||
}
|
||||
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return [...readIndex().values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export function mostRecentSessionId(): string | undefined {
|
||||
@@ -119,6 +199,22 @@ export function mostRecentSessionId(): string | undefined {
|
||||
export function deleteSession(id: string): boolean {
|
||||
const file = filePath(id);
|
||||
if (!existsSync(file)) return false;
|
||||
unlinkSync(file);
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch (err) {
|
||||
// ENOENT is harmless (race with another process) — the end state (file gone) is what we
|
||||
// wanted anyway, so fall through and report success. Any other error (e.g. EPERM/EBUSY from
|
||||
// a file lock, common on Windows) means the file is still on disk — report failure and leave
|
||||
// the index entry alone, or listSessions()/resume would silently orphan a file no one could
|
||||
// reference again (removed from the index, but never actually deleted).
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[sessionStore] failed to delete session file ${file}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const index = readIndex();
|
||||
index.delete(id);
|
||||
writeIndex(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -46,4 +46,21 @@ describe("loadPlugin", () => {
|
||||
expect(plugin.agents).toHaveLength(1);
|
||||
expect(plugin.agents[0]).toMatchObject({ name: "review", description: "Code reviewer" });
|
||||
});
|
||||
|
||||
it("parses a command's allowed-tools frontmatter, translating Claude Code tool names", () => {
|
||||
mkdirSync(path.join(tempDir, ".claude-plugin"), { recursive: true });
|
||||
writeFileSync(path.join(tempDir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "test-plugin" }));
|
||||
mkdirSync(path.join(tempDir, "commands"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(tempDir, "commands", "readonly.md"),
|
||||
"---\ndescription: Look but don't touch\nallowed-tools: Read, Grep\n---\nInvestigate $ARGUMENTS",
|
||||
);
|
||||
writeFileSync(path.join(tempDir, "commands", "unrestricted.md"), "---\ndescription: No restriction\n---\nDo $ARGUMENTS");
|
||||
|
||||
const plugin = loadPlugin(tempDir);
|
||||
const readonly = plugin.commands.find((c) => c.name === "readonly");
|
||||
const unrestricted = plugin.commands.find((c) => c.name === "unrestricted");
|
||||
expect(readonly?.allowedTools).toEqual(["read_file", "grep"]);
|
||||
expect(unrestricted?.allowedTools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-5
@@ -20,6 +20,17 @@ function listMarkdownFiles(dir: string): string[] {
|
||||
return readdirSync(dir).filter((f) => f.endsWith(".md"));
|
||||
}
|
||||
|
||||
/** Parses a comma-separated tool-name list from frontmatter (agents' `tools`, commands'
|
||||
* `allowed-tools`) and translates each from Claude Code's built-in names to locode's. Returns
|
||||
* undefined for an absent/empty field so callers can treat that as "no restriction". */
|
||||
function parseToolList(field: string | undefined): string[] | undefined {
|
||||
const tools = field
|
||||
?.split(",")
|
||||
.map((t) => resolveToolName(t.trim()))
|
||||
.filter(Boolean);
|
||||
return tools?.length ? tools : undefined;
|
||||
}
|
||||
|
||||
function loadCommands(pluginRoot: string, pluginName: string): PluginCommand[] {
|
||||
const dir = path.join(pluginRoot, "commands");
|
||||
return listMarkdownFiles(dir).map((entry) => {
|
||||
@@ -30,6 +41,7 @@ function loadCommands(pluginRoot: string, pluginName: string): PluginCommand[] {
|
||||
description: frontmatter.description,
|
||||
argumentHint: frontmatter["argument-hint"],
|
||||
template: body,
|
||||
allowedTools: parseToolList(frontmatter["allowed-tools"]),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -39,15 +51,11 @@ function loadAgents(pluginRoot: string, pluginName: string): PluginAgentDef[] {
|
||||
return listMarkdownFiles(dir).map((entry) => {
|
||||
const { frontmatter, body } = parseFrontmatter(readFileSync(path.join(dir, entry), "utf-8"));
|
||||
const name = frontmatter.name || entry.replace(/\.md$/, "");
|
||||
const tools = frontmatter.tools
|
||||
?.split(",")
|
||||
.map((t) => resolveToolName(t.trim()))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
pluginName,
|
||||
name,
|
||||
description: frontmatter.description || name,
|
||||
tools: tools?.length ? tools : undefined,
|
||||
tools: parseToolList(frontmatter.tools),
|
||||
systemPrompt: body,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface PluginCommand {
|
||||
/** The markdown body — expanded via $ARGUMENTS/$1../$9 (see expandTemplate.ts) and submitted
|
||||
* as the turn's input when the command is invoked. */
|
||||
template: string;
|
||||
/** Tool names this command's turn is restricted to (already translated from Claude Code's
|
||||
* built-in tool names — see toolNameMap.ts), from frontmatter `allowed-tools`; undefined means
|
||||
* no restriction (the command runs with the session's full toolset). */
|
||||
allowedTools?: string[];
|
||||
}
|
||||
|
||||
export interface PluginAgentDef {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseFallbackToolCalls } from "./fallbackParser.js";
|
||||
|
||||
describe("parseFallbackToolCalls", () => {
|
||||
it("parses the instructed ```tool_call fenced format", () => {
|
||||
const out = parseFallbackToolCalls(
|
||||
'```tool_call\n{"name":"read_file","arguments":{"path":"src/a.ts"}}\n```',
|
||||
);
|
||||
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/a.ts" } }]);
|
||||
expect(out.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no calls and not-malformed for plain prose with no tool attempt", () => {
|
||||
const out = parseFallbackToolCalls("Here is the answer: use foo().");
|
||||
expect(out.calls).toEqual([]);
|
||||
expect(out.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a tool_call block whose JSON is unparseable as malformed", () => {
|
||||
const out = parseFallbackToolCalls("```tool_call\n{not valid json\n```");
|
||||
expect(out.calls).toEqual([]);
|
||||
expect(out.malformed).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a ```json fenced block when no tool_call block is present", () => {
|
||||
const out = parseFallbackToolCalls(
|
||||
'```json\n{"name":"grep","arguments":{"pattern":"foo"}}\n```',
|
||||
);
|
||||
expect(out.calls).toEqual([{ name: "grep", arguments: { pattern: "foo" } }]);
|
||||
expect(out.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers a ```tool_call block over a ```json block when both appear", () => {
|
||||
const out = parseFallbackToolCalls(
|
||||
'```tool_call\n{"name":"read_file","arguments":{"path":"a"}}\n```\n' +
|
||||
'```json\n{"name":"grep","arguments":{"pattern":"x"}}\n```',
|
||||
);
|
||||
expect(out.calls).toHaveLength(1);
|
||||
expect(out.calls[0]!.name).toBe("read_file");
|
||||
});
|
||||
|
||||
it("does not treat a ```json fence inside a tool_call block as the terminator", () => {
|
||||
const out = parseFallbackToolCalls(
|
||||
"```tool_call\n```json\n{\"name\":\"read_file\",\"arguments\":{\"path\":\"a\"}}\n```\n```",
|
||||
);
|
||||
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "a" } }]);
|
||||
});
|
||||
|
||||
it("extracts a bare (unfenced) tool-call object from surrounding prose", () => {
|
||||
const out = parseFallbackToolCalls(
|
||||
'Let me read that file.\n{"name":"read_file","arguments":{"path":"src/loop.ts"}}\nThat should help.',
|
||||
);
|
||||
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/loop.ts" } }]);
|
||||
});
|
||||
|
||||
it("ignores bare braces in prose that don't look like a tool call", () => {
|
||||
const out = parseFallbackToolCalls("The config is { key: value } and that's it.");
|
||||
expect(out.calls).toEqual([]);
|
||||
expect(out.malformed).toBe(false);
|
||||
});
|
||||
|
||||
it("repairs a truncated JSON tool_call block via partial-JSON repair", () => {
|
||||
// max_tokens clipped the closing brace and quote
|
||||
const out = parseFallbackToolCalls('```tool_call\n{"name":"read_file","arguments":{"path":"src/lo');
|
||||
expect(out.calls).toEqual([{ name: "read_file", arguments: { path: "src/lo" } }]);
|
||||
});
|
||||
|
||||
it("accepts a tool call with omitted arguments as empty arguments", () => {
|
||||
const out = parseFallbackToolCalls('```tool_call\n{"name":"git_status"}\n```');
|
||||
expect(out.calls).toEqual([{ name: "git_status", arguments: {} }]);
|
||||
});
|
||||
|
||||
it("flags a tool call whose arguments is not an object", () => {
|
||||
const out = parseFallbackToolCalls('```tool_call\n{"name":"x","arguments":"foo"}\n```');
|
||||
expect(out.calls).toEqual([]);
|
||||
expect(out.malformed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { repairPartialJson } from "./partialJson.js";
|
||||
|
||||
export interface FallbackToolCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
@@ -8,25 +10,155 @@ export interface FallbackParseResult {
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
const BLOCK_RE = /```tool_call\s*([\s\S]*?)```/g;
|
||||
// Local models in fallback mode (no native function calling) are asked to emit tool calls as a
|
||||
// fenced ```tool_call block. In practice they frequently deviate, so the parser is lenient about
|
||||
// FORMAT but strict about CONTENT: anything we extract must still parse to { name, arguments }.
|
||||
// Accepted shapes, in priority order:
|
||||
// 1. A ```tool_call fenced block (the instructed format). The opening fence is ```tool_call on
|
||||
// its own line; the closing fence is ``` on its own line — anchored so a ```json block INSIDE
|
||||
// isn't mistaken for the terminator. Some models wrap the JSON in an inner ```json fence; we
|
||||
// strip that inner fence before parsing.
|
||||
// 2. A ```json fenced block whose content is a tool-call object (name + arguments). Models that
|
||||
// ignore the custom "tool_call" fence name but reach for the familiar "json" one.
|
||||
// 3. A bare tool-call object appearing in the response with no fence at all. We scan for the
|
||||
// first balanced {...} that contains a string "name" and an object "arguments". To avoid
|
||||
// matching arbitrary prose-embedded JSON, we require the recognizable keys.
|
||||
//
|
||||
// Every extracted candidate goes through `coerce`, which parses (with partial-JSON repair as a
|
||||
// last resort for truncated streaming output) and validates the name/arguments shape. A candidate
|
||||
// that doesn't yield a valid call sets `malformed` — the loop nudges the model to retry rather than
|
||||
// silently ending the task with no tool executed.
|
||||
|
||||
// Opening fence ```tool_call on its own line; closing ``` on its own line. Multiline-anchored so
|
||||
// an inner ```json fence can't be read as the terminator.
|
||||
const TOOL_CALL_FENCE_RE = /^```tool_call\s*\n([\s\S]*?)\n```(?:\n|$)/gm;
|
||||
// A ```json block — only used if no ```tool_call block matched, since a json fence may carry prose.
|
||||
const JSON_FENCE_RE = /^```json\s*\n([\s\S]*?)\n```(?:\n|$)/gm;
|
||||
|
||||
/** Pull the textual content out of a fenced block, stripping any inner ```json fence a model may
|
||||
* have nested inside it. Returns the cleaned, trimmed body. */
|
||||
function cleanFencedBody(raw: string): string {
|
||||
return raw.replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
|
||||
}
|
||||
|
||||
/** Parse a candidate string into a tool call, or null if it isn't one. Tries a direct parse, then a
|
||||
* partial-JSON repair (truncation / trailing comma / unbalanced braces) for clipped streaming. */
|
||||
function coerce(candidate: string): FallbackToolCall | null {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) return null;
|
||||
// Direct parse first.
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
// Repair pass for truncated / sloppy JSON from local streaming.
|
||||
if (parsed === null) parsed = repairPartialJson(trimmed);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.name !== "string") return null;
|
||||
if (obj.arguments === undefined || obj.arguments === null) {
|
||||
// Some models omit arguments entirely when the tool takes none — treat as empty.
|
||||
return { name: obj.name, arguments: {} };
|
||||
}
|
||||
if (typeof obj.arguments !== "object" || Array.isArray(obj.arguments)) return null;
|
||||
return { name: obj.name, arguments: obj.arguments as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** Scan `content` for the first balanced {...} object containing a `"name"` string and an
|
||||
* `"arguments"` object, with no fence at all. Tracks string literals so braces inside strings
|
||||
* don't affect nesting, and cuts to the first complete top-level object. */
|
||||
function findBareToolCall(content: string): string | null {
|
||||
const start = content.indexOf("{");
|
||||
if (start < 0) return null;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
for (let i = start; i < content.length; i++) {
|
||||
const c = content[i]!;
|
||||
if (inStr) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inStr = true;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = content.slice(start, i + 1);
|
||||
// Only accept it if it actually looks like a tool call — otherwise keep scanning.
|
||||
if (/"name"\s*:/.test(candidate) && /"arguments"\s*:/.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
// Reset to the next brace past this point to keep looking.
|
||||
const next = content.indexOf("{", i + 1);
|
||||
if (next < 0) return null;
|
||||
i = next - 1;
|
||||
depth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ran off the end with an unclosed object — a truncated tool call (max_tokens clipped the
|
||||
// closing brace, and possibly the closing fence too). Hand the unbalanced substring back;
|
||||
// `coerce` will run it through partial-JSON repair to close what's open.
|
||||
if (depth > 0) {
|
||||
const candidate = content.slice(start);
|
||||
if (/"name"\s*:/.test(candidate) && /"arguments"\s*:/.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseFallbackToolCalls(content: string): FallbackParseResult {
|
||||
const calls: FallbackToolCall[] = [];
|
||||
let malformed = false;
|
||||
let sawAnyCandidate = false;
|
||||
|
||||
for (const match of content.matchAll(BLOCK_RE)) {
|
||||
const raw = match[1]?.trim() ?? "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.name === "string" && typeof parsed.arguments === "object") {
|
||||
calls.push({ name: parsed.name, arguments: parsed.arguments ?? {} });
|
||||
} else {
|
||||
malformed = true;
|
||||
}
|
||||
} catch {
|
||||
malformed = true;
|
||||
// (1) ```tool_call fenced blocks (the instructed format) — there may be several.
|
||||
for (const match of content.matchAll(TOOL_CALL_FENCE_RE)) {
|
||||
sawAnyCandidate = true;
|
||||
const body = cleanFencedBody(match[1] ?? "");
|
||||
const call = coerce(body);
|
||||
if (call) calls.push(call);
|
||||
else malformed = true;
|
||||
}
|
||||
if (calls.length > 0) return { calls, malformed };
|
||||
|
||||
// (2) ```json fenced blocks — only if no tool_call block matched. Take the first that coerces.
|
||||
for (const match of content.matchAll(JSON_FENCE_RE)) {
|
||||
const call = coerce(cleanFencedBody(match[1] ?? ""));
|
||||
if (call) {
|
||||
calls.push(call);
|
||||
return { calls, malformed: false };
|
||||
}
|
||||
sawAnyCandidate = true;
|
||||
malformed = true;
|
||||
}
|
||||
if (calls.length > 0) return { calls, malformed };
|
||||
|
||||
// (3) Bare (unfenced) tool-call object — last resort. At most one: the prompt says one call per
|
||||
// response, and extracting multiple bare objects from prose is too error-prone.
|
||||
const bare = findBareToolCall(content);
|
||||
if (bare !== null) {
|
||||
sawAnyCandidate = true;
|
||||
const call = coerce(bare);
|
||||
if (call) {
|
||||
calls.push(call);
|
||||
return { calls, malformed: false };
|
||||
}
|
||||
malformed = true;
|
||||
}
|
||||
|
||||
// If we never saw anything that even looked like a tool-call attempt, that's not malformed —
|
||||
// the model simply answered in prose (no tool needed). `malformed` stays false.
|
||||
void sawAnyCandidate;
|
||||
return { calls, malformed };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { resolveToolCall } from "./nativeAdapter.js";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
|
||||
function makeRegistry(): Map<string, ToolDef> {
|
||||
const tool: ToolDef = {
|
||||
name: "write_file",
|
||||
description: "d",
|
||||
schema: z.object({ path: z.string(), content: z.string() }),
|
||||
mutating: true,
|
||||
handler: async () => ({}),
|
||||
};
|
||||
return new Map([["write_file", tool]]);
|
||||
}
|
||||
|
||||
function call(args: string) {
|
||||
return {
|
||||
id: "1",
|
||||
type: "function" as const,
|
||||
function: { name: "write_file", arguments: args },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveToolCall — argument repair", () => {
|
||||
it("resolves normally when arguments are already valid JSON", () => {
|
||||
const r = resolveToolCall(call('{"path":"a.ts","content":"hi"}'), makeRegistry());
|
||||
expect("tool" in r).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs arguments containing a raw (unescaped) newline instead of failing outright", () => {
|
||||
// A local model echoing multi-line file content into a non-streaming completion often pastes
|
||||
// a real newline byte into the JSON string rather than escaping it — this path (unlike the
|
||||
// streaming path in loop.ts) previously had no repair attempt at all.
|
||||
const args = '{"path":"a.ts","content":"line1\nline2"}';
|
||||
const r = resolveToolCall(call(args), makeRegistry());
|
||||
expect("tool" in r).toBe(true);
|
||||
if ("tool" in r) expect((r.args as { content: string }).content).toBe("line1\nline2");
|
||||
});
|
||||
|
||||
it("still errors when the arguments are unsalvageable", () => {
|
||||
const r = resolveToolCall(call("not json at all"), makeRegistry());
|
||||
expect(r).toEqual({ error: "arguments were not valid JSON" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatCompletionMessageToolCall, ChatCompletionTool } from "openai/resources/chat/completions";
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "../tools/types.js";
|
||||
import { repairPartialJson } from "./partialJson.js";
|
||||
import { resolveToolInvocation, type ResolvedToolCall } from "./resolve.js";
|
||||
|
||||
export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
|
||||
@@ -25,7 +26,13 @@ export function resolveToolCall(
|
||||
try {
|
||||
parsedArgs = JSON.parse(call.function.arguments || "{}");
|
||||
} catch {
|
||||
return { error: "arguments were not valid JSON" };
|
||||
// Non-streaming completions land here directly (unlike the streaming path in loop.ts, which
|
||||
// already repairs before this point) — without a repair attempt here too, a call whose
|
||||
// arguments contain e.g. an unescaped literal newline (a local model echoing multi-line file
|
||||
// content raw) fails outright instead of being salvaged.
|
||||
const repaired = repairPartialJson(call.function.arguments || "");
|
||||
if (repaired === null) return { error: "arguments were not valid JSON" };
|
||||
parsedArgs = repaired;
|
||||
}
|
||||
return resolveToolInvocation(call.function.name, parsedArgs, registry);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { repairPartialJson } from "./partialJson.js";
|
||||
|
||||
describe("repairPartialJson", () => {
|
||||
it("parses already-valid JSON unchanged", () => {
|
||||
expect(repairPartialJson('{"path":"src/a.ts"}')).toEqual({ path: "src/a.ts" });
|
||||
expect(repairPartialJson("[]")).toEqual([]);
|
||||
expect(repairPartialJson(" 42 ")).toBe(42);
|
||||
expect(repairPartialJson('{"a":1}\n')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("returns null for empty / whitespace input", () => {
|
||||
expect(repairPartialJson("")).toBeNull();
|
||||
expect(repairPartialJson(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips a trailing comma before a closing bracket", () => {
|
||||
expect(repairPartialJson('{"a":1,}')).toEqual({ a: 1 });
|
||||
expect(repairPartialJson('[1,2,]')).toEqual([1, 2]);
|
||||
expect(repairPartialJson('{"a":{"b":2,},}')).toEqual({ a: { b: 2 } });
|
||||
});
|
||||
|
||||
it("strips stray trailing content after a complete value", () => {
|
||||
expect(repairPartialJson('{"a":1}\n```')).toEqual({ a: 1 });
|
||||
expect(repairPartialJson('{"a":1}garbage')).toEqual({ a: 1 });
|
||||
expect(repairPartialJson('[1,2] }')).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("closes a truncated string value", () => {
|
||||
// max_tokens clipped mid-value: {"path":"src/lo → needs closing quote + brace
|
||||
expect(repairPartialJson('{"path":"src/lo')).toEqual({ path: "src/lo" });
|
||||
expect(repairPartialJson('{"a":"hello wor')).toEqual({ a: "hello wor" });
|
||||
});
|
||||
|
||||
it("balances unclosed braces and brackets from truncation", () => {
|
||||
expect(repairPartialJson('{"a":1')).toEqual({ a: 1 });
|
||||
expect(repairPartialJson('{"a":{"b":2')).toEqual({ a: { b: 2 } });
|
||||
expect(repairPartialJson("[1,2")).toEqual([1, 2]);
|
||||
expect(repairPartialJson('{"items":[1,2')).toEqual({ items: [1, 2] });
|
||||
});
|
||||
|
||||
it("does not count braces inside string literals", () => {
|
||||
// The braces/brackets inside the string are content, not nesting.
|
||||
expect(repairPartialJson('{"code":"func() { return [1] "')).toEqual({
|
||||
code: "func() { return [1] ",
|
||||
});
|
||||
expect(repairPartialJson('{"s":"\\\"escaped\\\""}')).toEqual({ s: '"escaped"' });
|
||||
});
|
||||
|
||||
it("handles escaped quotes inside strings during truncation repair", () => {
|
||||
// Unterminated string with an escaped quote inside: {"s":"a\"b
|
||||
expect(repairPartialJson('{"s":"a\\"b')).toEqual({ s: 'a"b' });
|
||||
});
|
||||
|
||||
it("combined: trailing comma exposed after balancing", () => {
|
||||
// {"a":1,"b":2, (truncated with trailing comma) → close brace, then strip comma
|
||||
expect(repairPartialJson('{"a":1,"b":2,')).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("returns null when input is not salvageable as object/array/scalar", () => {
|
||||
expect(repairPartialJson("just prose with no json")).toBeNull();
|
||||
expect(repairPartialJson("{:}")).toBeNull();
|
||||
});
|
||||
|
||||
it("escapes a raw (unescaped) literal newline inside a string value", () => {
|
||||
// A local model echoing multi-line file content often pastes real \n bytes into the JSON
|
||||
// string instead of writing the two-char `\n` escape — JSON.parse rejects that outright.
|
||||
expect(repairPartialJson('{"content":"line1\nline2"}')).toEqual({ content: "line1\nline2" });
|
||||
});
|
||||
|
||||
it("escapes a raw carriage return inside a string value (CRLF source content)", () => {
|
||||
expect(repairPartialJson('{"content":"line1\r\nline2"}')).toEqual({ content: "line1\r\nline2" });
|
||||
});
|
||||
|
||||
it("leaves an already-escaped \\n sequence untouched", () => {
|
||||
expect(repairPartialJson('{"content":"line1\\nline2"}')).toEqual({ content: "line1\nline2" });
|
||||
});
|
||||
|
||||
it("combines raw-newline escaping with truncation repair", () => {
|
||||
// Truncated mid-value AND containing a raw newline earlier in the string.
|
||||
expect(repairPartialJson('{"content":"line1\nline2')).toEqual({ content: "line1\nline2" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
// Partial/truncated JSON repair for native streaming tool-call arguments.
|
||||
//
|
||||
// Local-model backends (Ollama, LM Studio) streaming tool calls accumulate the `arguments` string
|
||||
// across deltas. Two common pathologies produce a string that JSON.parse rejects but that contains
|
||||
// all the semantic content the model intended:
|
||||
//
|
||||
// 1. TRUNCATION — max_tokens clipped the JSON mid-value. The string ends inside a string value,
|
||||
// an array, or an object: `{"path": "src/lo`, `{"items": [1, 2`, `{"a": {"b": 1`.
|
||||
// 2. LOCAL-MODEL SLOPPINESS — a trailing comma, an unbalanced brace/bracket, or a trailing
|
||||
// garbage token after the closing brace: `{"path": "x.ts",}`, `{"a": 1 `.
|
||||
//
|
||||
// This module attempts a cheap, conservative repair BEFORE the caller falls back to a full
|
||||
// non-streaming regeneration (which is expensive on a local backend and often fails identically
|
||||
// when the cause was max_tokens). It only closes what's open and trims what's stray — it never
|
||||
// invents keys or values, so a genuinely malformed call still fails downstream at schema validation.
|
||||
//
|
||||
// The repair is best-effort: if it can't produce parseable JSON, it returns null and the caller
|
||||
// keeps its existing retry path. It is deliberately string-based (no AST) so it's trivially fast and
|
||||
// has no dependencies, and so it handles truncated input that a strict parser can't even build an
|
||||
// AST from.
|
||||
|
||||
/** Parse `s` as JSON; on success return the value, on failure return null (never throws). */
|
||||
function tryParse(s: string): unknown {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON disallows raw control characters (0x00-0x1F — notably literal newline, CR, tab) inside
|
||||
* string literals; they must be written as `\n`/`\r`/`\t`/`\u00XX`. Local models echoing
|
||||
* multi-line file content (very common for write_file/edit_file on this project's CRLF-heavy
|
||||
* source) routinely paste it in unescaped, which makes an otherwise complete, semantically
|
||||
* correct tool call fail JSON.parse with "Bad control character in string literal". Escaping
|
||||
* only touches raw bytes found *inside* a string (tracked the same way `skipString` does, so an
|
||||
* existing `\\n` escape sequence is left alone) — it never changes where a string starts/ends or
|
||||
* where a brace/bracket falls outside one, so it's safe to run before the other repair steps. */
|
||||
function escapeRawControlCharsInStrings(s: string): string {
|
||||
let out = "";
|
||||
let inStr = false;
|
||||
let changed = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]!;
|
||||
if (!inStr) {
|
||||
if (c === '"') inStr = true;
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
if (c === "\\") {
|
||||
// Preserve an existing escape sequence verbatim — don't touch the char after the backslash.
|
||||
out += c + (s[i + 1] ?? "");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inStr = false;
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
const code = c.charCodeAt(0);
|
||||
if (code < 0x20) {
|
||||
changed = true;
|
||||
if (c === "\n") out += "\\n";
|
||||
else if (c === "\r") out += "\\r";
|
||||
else if (c === "\t") out += "\\t";
|
||||
else out += "\\u" + code.toString(16).padStart(4, "0");
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
return changed ? out : s;
|
||||
}
|
||||
|
||||
/** Skip past the next JSON string literal starting at `i` (the opening quote). Returns the index
|
||||
* just past the closing quote. Strings are the only place braces/brackets can appear without
|
||||
* affecting nesting, so we must not count them while inside one. Handles `\"` and other escapes. */
|
||||
function skipString(s: string, i: number): number {
|
||||
let j = i + 1; // past opening quote
|
||||
for (; j < s.length; j++) {
|
||||
const c = s[j]!;
|
||||
if (c === "\\") {
|
||||
j++; // skip the escaped char (covers \", \\, etc.)
|
||||
continue;
|
||||
}
|
||||
if (c === '"') return j + 1; // past closing quote
|
||||
}
|
||||
return j; // ran off the end — unterminated string
|
||||
}
|
||||
|
||||
/** Attempts to repair `raw` into parseable JSON. Returns the parsed value on success, or null if no
|
||||
* repair produced valid JSON. Steps, applied in order of how cheap and safe they are:
|
||||
*
|
||||
* 1. Maybe it already parses (trailing whitespace/newlines are fine for JSON.parse) — return as-is.
|
||||
* 2. Escape raw control characters (literal newline/CR/tab) found inside string literals — a
|
||||
* model echoing multi-line file content unescaped, common on this project's CRLF sources.
|
||||
* 3. Strip a trailing comma before an expected-but-absent `}` or `]` (common local-model slip).
|
||||
* 4. Strip stray non-JSON tokens after the first complete top-level value (`{"a":1}\n` → `{"a":1}`,
|
||||
* and `{"a":1}garbage` → `{"a":1}` — JSON.parse rejects trailing content, so trim to the first
|
||||
* complete value).
|
||||
* 5. Close unterminated strings, then balance still-open braces/brackets (truncation repair).
|
||||
*
|
||||
* Each step re-attempts a parse, so the cheapest fix that works wins. */
|
||||
export function repairPartialJson(raw: string): unknown | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// (1) Already valid?
|
||||
const direct = tryParse(trimmed);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// (2) Raw control characters (literal newlines/CR/tab) inside string literals — see
|
||||
// escapeRawControlCharsInStrings for why this is common. Escaping never moves a quote or
|
||||
// brace, so every remaining step below runs against this version instead of the original.
|
||||
const working = escapeRawControlCharsInStrings(trimmed);
|
||||
if (working !== trimmed) {
|
||||
const v = tryParse(working);
|
||||
if (v !== null) return v;
|
||||
}
|
||||
|
||||
// (3) Trailing comma before end-of-object/array: `{"a":1,}` or `[1,2,]`. Repeat until none
|
||||
// left so a nested shape like `{"a":{"b":2,},}` clears both commas (innermost-first).
|
||||
let noTrailing = working;
|
||||
let prev: string;
|
||||
do {
|
||||
prev = noTrailing;
|
||||
noTrailing = noTrailing.replace(/,\s*([\]}]+\s*$)/, "$1");
|
||||
} while (noTrailing !== prev);
|
||||
if (noTrailing !== working) {
|
||||
const v = tryParse(noTrailing);
|
||||
if (v !== null) return v;
|
||||
}
|
||||
|
||||
// (4) Stray trailing content after the first complete value. JSON.parse refuses trailing tokens,
|
||||
// but a model often emits a closing brace then a stray newline, a repeated token, or prose.
|
||||
// Find the end of the first balanced top-level value and cut there.
|
||||
const cut = cutToFirstCompleteValue(working);
|
||||
if (cut !== null && cut !== working) {
|
||||
const v = tryParse(cut);
|
||||
if (v !== null) return v;
|
||||
}
|
||||
|
||||
// (5) Truncation repair: close an unterminated string, then balance open braces/brackets.
|
||||
const balanced = balanceAndClose(working);
|
||||
if (balanced !== null && balanced !== working) {
|
||||
// Re-run the earlier cheap fixes on the balanced result (a trailing comma may now be exposed).
|
||||
const v = tryParse(balanced);
|
||||
if (v !== null) return v;
|
||||
const v2 = tryParse(balanced.replace(/,\s*([\]}]\s*$)/, "$1"));
|
||||
if (v2 !== null) return v2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** If `s` starts with a complete top-level JSON value followed by stray content, return just that
|
||||
* value (as a substring). Returns null if we can't find a clean boundary (e.g. the value is itself
|
||||
* truncated). Walks the string tracking string literals and nesting depth. */
|
||||
function cutToFirstCompleteValue(s: string): string | null {
|
||||
let i = 0;
|
||||
// Skip leading whitespace.
|
||||
while (i < s.length && /\s/.test(s[i]!)) i++;
|
||||
if (i >= s.length) return null;
|
||||
|
||||
const start = i;
|
||||
const stack: string[] = [];
|
||||
let inStr = false;
|
||||
|
||||
while (i < s.length) {
|
||||
const c = s[i]!;
|
||||
if (inStr) {
|
||||
if (c === "\\") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inStr = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "{" || c === "[") {
|
||||
stack.push(c);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "}" || c === "]") {
|
||||
stack.pop();
|
||||
i++;
|
||||
// If the stack is empty, this was the end of the top-level value — cut here.
|
||||
if (stack.length === 0) return s.slice(start, i);
|
||||
continue;
|
||||
}
|
||||
// A bare scalar (number/true/false/null) ends at the next delimiter/comma/whitespace.
|
||||
if (stack.length === 0 && (c === "," || c === "}" || c === "]" || /\s/.test(c))) {
|
||||
return s.slice(start, i);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Ran off the end without closing the top-level value → it's truncated, not "complete + stray".
|
||||
if (stack.length > 0) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Closes an unterminated trailing string and balances any open braces/brackets. Returns the
|
||||
* repaired string, or null if nothing needed closing (caller can compare to skip a no-op parse). */
|
||||
function balanceAndClose(s: string): string | null {
|
||||
let out = s;
|
||||
const stack: string[] = [];
|
||||
let inStr = false;
|
||||
let i = 0;
|
||||
|
||||
for (; i < out.length; i++) {
|
||||
const c = out[i]!;
|
||||
if (inStr) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inStr = true;
|
||||
continue;
|
||||
}
|
||||
if (c === "{") stack.push("}");
|
||||
else if (c === "[") stack.push("]");
|
||||
else if (c === "}" || c === "]") stack.pop();
|
||||
}
|
||||
|
||||
// If we ended inside a string, close it. A truncated value like `{"path":"src/lo` needs a closing
|
||||
// quote before we can balance the outer braces.
|
||||
if (inStr) {
|
||||
out += '"';
|
||||
}
|
||||
|
||||
// Close anything still open, innermost-first. Truncation mid-array/object → append the closers.
|
||||
if (stack.length === 0 && !inStr) {
|
||||
// Nothing to close — but a trailing comma may have been the only issue; let the caller handle it.
|
||||
return inStr ? out : null;
|
||||
}
|
||||
while (stack.length) {
|
||||
out += stack.pop();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+48
-5
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { SubAgentResult } from "./types.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -7,22 +8,64 @@ const schema = z.object({
|
||||
.string()
|
||||
.describe(
|
||||
"Full, self-contained task description. The sub-agent has no conversation memory and cannot ask follow-ups.",
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
tasks: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().describe("Short label for this sub-task."),
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
"Full, self-contained task description for this sub-task. The sub-agent has no conversation memory.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.min(2)
|
||||
.describe(
|
||||
"Run several sub-agents IN PARALLEL (read-heavy research/audit tasks). Each gets its own " +
|
||||
"isolated context and returns independently. Use this for many files, e.g. one sub-agent " +
|
||||
"per directory or per concern (security, performance, tests). Prefer `prompt` for a single task.",
|
||||
)
|
||||
.optional(),
|
||||
}).refine((v) => v.prompt || v.tasks, {
|
||||
message: "Provide either `prompt` (single sub-agent) or `tasks` (parallel batch).",
|
||||
});
|
||||
|
||||
export const agentTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "agent",
|
||||
description:
|
||||
"Delegate a task to a sub-agent with its own tool loop (no nested agents). Only the final answer is returned. " +
|
||||
"For many files, split into multiple sub-agents. Sub-agents have a smaller step budget; if one runs out, " +
|
||||
"narrow the task rather than retrying.",
|
||||
"For many files, split into multiple sub-agents — pass a `tasks` array to run several in PARALLEL " +
|
||||
"(each returns independently; one failure doesn't discard the others). Sub-agents have a smaller " +
|
||||
"step budget; if one runs out, narrow the task rather than retrying. Mutating tool calls from any " +
|
||||
"sub-agent still go through the same permission prompts (serialized, so parallel sub-agents that " +
|
||||
"edit don't race).",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.runSubAgent) {
|
||||
const runSubAgent = ctx.runSubAgent;
|
||||
if (!runSubAgent) {
|
||||
throw new Error("Sub-agents are not available in this context.");
|
||||
}
|
||||
const result = await ctx.runSubAgent(args);
|
||||
// Parallel batch: run each task as its own sub-agent, concurrently. A single failure surfaces
|
||||
// as that task's `error` rather than rejecting the whole batch — the model gets every sibling's
|
||||
// result and can retry just the one that failed instead of paying for all of them again.
|
||||
if (args.tasks && args.tasks.length > 0) {
|
||||
const results = await Promise.all(
|
||||
args.tasks.map(async (t): Promise<SubAgentResult> => {
|
||||
try {
|
||||
const result = await runSubAgent({ description: t.description, prompt: t.prompt });
|
||||
return { description: t.description, result };
|
||||
} catch (err) {
|
||||
return { description: t.description, error: (err as Error).message ?? String(err) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { description: args.description, results };
|
||||
}
|
||||
// Single sub-agent (the original path).
|
||||
const result = await runSubAgent({ description: args.description, prompt: args.prompt ?? "" });
|
||||
return { description: args.description, result };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { execa } from "execa";
|
||||
import type { ResultPromise } from "execa";
|
||||
import { bashTool } from "./bash.js";
|
||||
import { killProcessTree } from "../utils/processTree.js";
|
||||
@@ -80,4 +81,36 @@ describe("bash tool — sub-agent abort", () => {
|
||||
|
||||
expect(killProcessTree).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bash tool — safety guards", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(execa).mockClear();
|
||||
});
|
||||
|
||||
it("refuses a command that wipes the filesystem root before ever spawning it", async () => {
|
||||
const ctx: ToolContext = { cwd: process.cwd() };
|
||||
await expect(bashTool.handler({ command: "rm -rf /" }, ctx)).rejects.toThrow(/Refusing to run/);
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not flag an ordinary rm -rf on a project subdirectory", async () => {
|
||||
// preview (not handler) so this doesn't spawn the fake child, which only ever resolves when
|
||||
// killed/backgrounded — nothing here would do either, so awaiting handler() would hang.
|
||||
const ctx: ToolContext = { cwd: process.cwd() };
|
||||
const preview = await bashTool.preview!({ command: "rm -rf node_modules" }, ctx);
|
||||
expect(preview).not.toMatch(/^Blocked:/);
|
||||
});
|
||||
|
||||
it("refuses a cwd override that escapes the working directory", async () => {
|
||||
const ctx: ToolContext = { cwd: process.cwd() };
|
||||
await expect(bashTool.handler({ command: "ls", cwd: "../../" }, ctx)).rejects.toThrow(/Refusing to write outside/);
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preview surfaces the block reason instead of running the command", async () => {
|
||||
const ctx: ToolContext = { cwd: process.cwd() };
|
||||
const preview = await bashTool.preview!({ command: "mkfs.ext4 /dev/sda1" }, ctx);
|
||||
expect(preview).toMatch(/^Blocked:/);
|
||||
});
|
||||
});
|
||||
+27
-11
@@ -1,7 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { execa } from "execa";
|
||||
import { z } from "zod";
|
||||
import { registerBackgroundJob } from "./backgroundJobs.js";
|
||||
import { riskyBashCommandReason } from "./bashGuard.js";
|
||||
import { resolveWithinCwd } from "./pathGuard.js";
|
||||
import { killProcessTree } from "../utils/processTree.js";
|
||||
import { truncate } from "../utils/truncate.js";
|
||||
import { resolveShell } from "../utils/shell.js";
|
||||
@@ -21,24 +22,39 @@ function delay(ms: number): Promise<"pending"> {
|
||||
|
||||
export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "bash",
|
||||
description: "Run a shell command and return its stdout, stderr, and exit code.",
|
||||
description: "Run a shell command and return its stdout, stderr, and exit code. Use for building, running tests, git operations, or inspecting the environment. Output is capped (head+tail preserved); long-running commands can be backgrounded with Ctrl+B and checked with bash_output.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ command, cwd }) => `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`,
|
||||
preview: async ({ command, cwd }, ctx) => {
|
||||
const riskyReason = riskyBashCommandReason(command);
|
||||
if (riskyReason) return `Blocked: this command ${riskyReason}.`;
|
||||
if (cwd) {
|
||||
try {
|
||||
resolveWithinCwd(ctx.cwd, cwd);
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
}
|
||||
return `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`;
|
||||
},
|
||||
handler: async ({ command, cwd, timeout_ms }, ctx) => {
|
||||
const workDir = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
|
||||
const riskyReason = riskyBashCommandReason(command);
|
||||
if (riskyReason) {
|
||||
throw new Error(`Refusing to run: this command ${riskyReason}.`);
|
||||
}
|
||||
const workDir = cwd ? resolveWithinCwd(ctx.cwd, cwd) : ctx.cwd;
|
||||
// Timeout is enforced by our own timer rather than execa's built-in `timeout` option, so that
|
||||
// backgrounding via Ctrl+B can cancel it below — execa's own timeout kills the process on a
|
||||
// fixed schedule regardless of what happens to it afterward, which would silently kill a
|
||||
// long-running command right after the user chose to keep it running in the background.
|
||||
const child = execa(command, { shell: resolveShell(), cwd: workDir, reject: false });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const onStdout = (d: Buffer) => {
|
||||
stdout += d.toString();
|
||||
stdoutChunks.push(d.toString());
|
||||
};
|
||||
const onStderr = (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
stderrChunks.push(d.toString());
|
||||
};
|
||||
child.stdout?.on("data", onStdout);
|
||||
child.stderr?.on("data", onStderr);
|
||||
@@ -78,7 +94,7 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
// long-running backgrounded job. The buffers captured so far seed the job.
|
||||
child.stdout?.off("data", onStdout);
|
||||
child.stderr?.off("data", onStderr);
|
||||
const job = registerBackgroundJob(command, workDir, child, stdout, stderr);
|
||||
const job = registerBackgroundJob(command, workDir, child, stdoutChunks.join(""), stderrChunks.join(""));
|
||||
return {
|
||||
backgrounded: true,
|
||||
jobId: job.id,
|
||||
@@ -90,8 +106,8 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
|
||||
clearTimeout(foregroundTimer);
|
||||
return {
|
||||
exitCode: settled.exitCode,
|
||||
stdout: truncate(stdout),
|
||||
stderr: truncate(stderr),
|
||||
stdout: truncate(stdoutChunks.join("")),
|
||||
stderr: truncate(stderrChunks.join("")),
|
||||
timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { riskyBashCommandReason } from "./bashGuard.js";
|
||||
|
||||
describe("riskyBashCommandReason", () => {
|
||||
describe("blocks", () => {
|
||||
const cases: [name: string, command: string][] = [
|
||||
["rm -rf /", "rm -rf /"],
|
||||
["rm -fr / (flag order swapped)", "rm -fr /"],
|
||||
["rm -rf / with a trailing slash-star", "rm -rf /*"],
|
||||
["rm -Rf ~ (home dir)", "rm -Rf ~"],
|
||||
["rm --recursive --force /", "rm --recursive --force /"],
|
||||
["sudo rm -rf /", "sudo rm -rf /"],
|
||||
["classic fork bomb", ":(){ :|:& };:"],
|
||||
["fork bomb with extra whitespace", ": ( ) { : | : & } ; :"],
|
||||
["mkfs.ext4", "mkfs.ext4 /dev/sda1"],
|
||||
["dd to a raw device", "dd if=/dev/zero of=/dev/sda bs=1M"],
|
||||
["redirect onto a raw device", "echo oops > /dev/sda"],
|
||||
["Windows format", "format C:"],
|
||||
["Windows rd /s /q on a drive root", "rd /s /q C:\\"],
|
||||
["PowerShell Remove-Item -Recurse -Force on a drive", "Remove-Item -Recurse -Force C:\\"],
|
||||
];
|
||||
for (const [name, command] of cases) {
|
||||
it(name, () => {
|
||||
expect(riskyBashCommandReason(command)).not.toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("does not block", () => {
|
||||
const cases: [name: string, command: string][] = [
|
||||
["rm -rf on a project subdirectory", "rm -rf node_modules"],
|
||||
["rm -rf on a relative build dir", "rm -rf ./dist"],
|
||||
["rm without force/recursive on root-looking arg", "rm /tmp/foo.txt"],
|
||||
["dd between two regular files", "dd if=file.img of=out.img"],
|
||||
["a command that merely contains the word format", "echo 'please format your PR title'"],
|
||||
["a normal git command", "git status"],
|
||||
["listing a directory named format", "ls format"],
|
||||
];
|
||||
for (const [name, command] of cases) {
|
||||
it(name, () => {
|
||||
expect(riskyBashCommandReason(command)).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Blocks a small set of unambiguously catastrophic shell commands — wiping the whole filesystem
|
||||
* or a whole drive, formatting a device, a fork bomb — before they ever reach the confirmation
|
||||
* prompt (or, under auto-accept, before they'd run with no prompt at all). This is not a general
|
||||
* command sandbox: it doesn't stop a model from `rm -rf`-ing some *other* directory it shouldn't,
|
||||
* running a slow fork loop that isn't the canonical bomb syntax, or anything merely inadvisable —
|
||||
* only the handful of patterns whose only realistic purpose is destroying the whole machine, where
|
||||
* a false negative is far more likely than a false positive. Deliberately narrow so it doesn't
|
||||
* reject legitimate commands like `rm -rf node_modules` or `dd if=file.img of=out.img`. */
|
||||
|
||||
interface RiskyPattern {
|
||||
test: (command: string) => boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Splits on whitespace for a crude token scan — good enough for a blocklist (not a security
|
||||
* boundary; execa still runs the raw string through a real shell either way) and avoids a brittle
|
||||
* do-everything regex that has to encode flag ordering itself. */
|
||||
function tokenize(command: string): string[] {
|
||||
return command.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
const ROOT_TARGETS = new Set(["/", "/*", "~", "~/", "~/*", "$home", "${home}"]);
|
||||
|
||||
/** `rm -rf /`, `rm -fr ~`, `sudo rm -Rf --no-preserve-root /`, etc. — recursive+forced deletion
|
||||
* whose target is the filesystem root or the whole home directory, in any flag order/spelling. */
|
||||
function isRmWipingRootOrHome(command: string): boolean {
|
||||
const tokens = tokenize(command).map((t) => t.toLowerCase());
|
||||
const rmIdx = tokens.findIndex((t) => t === "rm" || t.endsWith("/rm"));
|
||||
if (rmIdx === -1) return false;
|
||||
const rest = tokens.slice(rmIdx + 1);
|
||||
const isFlag = (t: string) => t.startsWith("-");
|
||||
const hasForce = rest.some((t) => (isFlag(t) && !t.startsWith("--") && t.includes("f")) || t === "--force");
|
||||
const hasRecursive = rest.some((t) => (isFlag(t) && !t.startsWith("--") && (t.includes("r") || t.includes("R"))) || t === "--recursive");
|
||||
const targets = rest.filter((t) => !isFlag(t));
|
||||
return hasForce && hasRecursive && targets.some((t) => ROOT_TARGETS.has(t));
|
||||
}
|
||||
|
||||
const RISKY_PATTERNS: RiskyPattern[] = [
|
||||
{
|
||||
test: isRmWipingRootOrHome,
|
||||
reason: "recursively force-deletes the filesystem root or home directory",
|
||||
},
|
||||
{
|
||||
// Classic bash fork bomb: ":(){ :|:& };:" (whitespace-tolerant).
|
||||
test: (cmd) => /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&?\s*;?\s*\}\s*;\s*:/.test(cmd),
|
||||
reason: "is a fork bomb (unbounded process spawning)",
|
||||
},
|
||||
{
|
||||
test: (cmd) => /\bmkfs(\.\w+)?\b/i.test(cmd),
|
||||
reason: "formats a filesystem (mkfs)",
|
||||
},
|
||||
{
|
||||
test: (cmd) => /\bdd\b[^\n]*\bof=\/dev\/(sd|hd|nvme|disk|xvd|rdisk)\w*/i.test(cmd),
|
||||
reason: "writes raw data directly to a block device (dd of=/dev/...)",
|
||||
},
|
||||
{
|
||||
test: (cmd) => />\s*\/dev\/(sd|hd|nvme|disk|xvd|rdisk)\w*\b/i.test(cmd),
|
||||
reason: "redirects output directly onto a block device",
|
||||
},
|
||||
{
|
||||
// `format C:`, `format /Y D:` — Windows drive format.
|
||||
test: (cmd) => /\bformat\b[^\n]*\b[a-zA-Z]:/i.test(cmd),
|
||||
reason: "formats a Windows drive (format)",
|
||||
},
|
||||
{
|
||||
// `rd /s /q C:\`, `rmdir /s /q D:\` — recursive quiet delete of a bare drive root.
|
||||
test: (cmd) => /\b(rd|rmdir)\b[^\n]*\/s\b[^\n]*\b[a-zA-Z]:\\?\s*(\/q\b[^\n]*)?$/im.test(cmd),
|
||||
reason: "recursively deletes an entire Windows drive",
|
||||
},
|
||||
{
|
||||
// PowerShell `Remove-Item -Recurse -Force C:\` (or -Path C:\, or $env:SystemDrive), flag order-tolerant.
|
||||
test: (cmd) =>
|
||||
/remove-item\b/i.test(cmd) &&
|
||||
/-recurse\b/i.test(cmd) &&
|
||||
/-force\b/i.test(cmd) &&
|
||||
(/\b[a-zA-Z]:\\?\s*($|['")\s;])/.test(cmd) || /\$env:systemdrive\b/i.test(cmd)),
|
||||
reason: "recursively force-deletes an entire Windows drive (Remove-Item)",
|
||||
},
|
||||
];
|
||||
|
||||
/** Returns a human-readable reason if `command` matches a known catastrophic pattern, else null. */
|
||||
export function riskyBashCommandReason(command: string): string | null {
|
||||
for (const pattern of RISKY_PATTERNS) {
|
||||
if (pattern.test(command)) return pattern.reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The LSP tools (definition/references/diagnostics) are thin dispatchers over lspManager. We mock
|
||||
// the manager functions so the tests run without spawning a language server, and assert each tool
|
||||
// forwards the right arguments (path resolved against cwd, 1-indexed→handled by the manager,
|
||||
// includeDeclaration default) and returns the manager's result verbatim.
|
||||
|
||||
vi.mock("../codeintel/lspManager.js", () => ({
|
||||
getDefinition: vi.fn(async () => ({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
|
||||
getReferences: vi.fn(async () => ({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
|
||||
getDiagnostics: vi.fn(async () => ({
|
||||
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
|
||||
})),
|
||||
}));
|
||||
|
||||
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
|
||||
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
|
||||
|
||||
const ctx = { cwd: "/proj" };
|
||||
|
||||
describe("definition tool", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("forwards path/line/column/cwd to getDefinition and returns its result", async () => {
|
||||
const out = await definitionTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
|
||||
expect(getDefinition).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj");
|
||||
expect(out).toEqual({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
|
||||
});
|
||||
|
||||
it("is read-only (no permission prompt)", () => {
|
||||
expect(definitionTool.mutating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("references tool", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("defaults includeDeclaration to true when omitted", async () => {
|
||||
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
|
||||
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", true);
|
||||
});
|
||||
|
||||
it("passes an explicit includeDeclaration through", async () => {
|
||||
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5, include_declaration: false }, ctx);
|
||||
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", false);
|
||||
});
|
||||
|
||||
it("returns the manager's references result", async () => {
|
||||
const out = await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
|
||||
expect(out).toEqual({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostics tool", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("forwards path/cwd to getDiagnostics and returns its result", async () => {
|
||||
const out = await diagnosticsTool.handler({ path: "src/a.ts" }, ctx);
|
||||
expect(getDiagnostics).toHaveBeenCalledWith("src/a.ts", "/proj");
|
||||
expect(out).toEqual({
|
||||
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("is read-only", () => {
|
||||
expect(diagnosticsTool.mutating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
|
||||
|
||||
// All three tools are read-only LSP queries. They share a common shape: point them at a file
|
||||
// (relative to cwd) and a 1-indexed line/column, and they ask the language server for the answer.
|
||||
// The server is lazily started on first use per language (tsserver, pyright, gopls, clangd,
|
||||
// rust-analyzer) and reused across the whole session — see lspManager.ts for the lifecycle.
|
||||
//
|
||||
// The error messages from lspManager are written to be actionable (e.g. "install
|
||||
// typescript-language-server"), so we let them surface verbatim rather than wrapping them — a
|
||||
// generic "LSP unavailable" would hide the one piece of info the model needs to recover.
|
||||
|
||||
const positionSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("File path, relative to the working directory. Must match an extension with a configured LSP server (.ts/.tsx/.js/.jsx/.py/.go/.rs/.c/.cpp/…)."),
|
||||
line: z.number().int().min(1).describe("1-indexed line number of the symbol to query."),
|
||||
column: z.number().int().min(1).describe("1-indexed column number of the symbol to query."),
|
||||
});
|
||||
|
||||
export const definitionTool: ToolDef<z.infer<typeof positionSchema>> = {
|
||||
name: "definition",
|
||||
description:
|
||||
"Resolve where a symbol is DEFINED using the language server (LSP). Use when grep finds a call site but you need the actual declaration — e.g. a function/variable/type name at a line:column. Returns one or more file:line:column locations (empty list if the server couldn't resolve it, which is a legitimate 'not found', not an error). Requires the relevant language server on PATH (typescript-language-server, pyright-langserver, gopls, clangd, or rust-analyzer).",
|
||||
schema: positionSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => getDefinition(args.path, args.line, args.column, ctx.cwd),
|
||||
};
|
||||
|
||||
const referencesSchema = positionSchema.extend({
|
||||
include_declaration: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to include the symbol's own declaration among the references. Defaults to true (matches most IDE 'find all references' behavior)."),
|
||||
});
|
||||
|
||||
export const referencesTool: ToolDef<z.infer<typeof referencesSchema>> = {
|
||||
name: "references",
|
||||
description:
|
||||
"Find every reference to a symbol using the language server (LSP) — the same as an IDE's 'find all references'. Use to enumerate all call/usage sites of a function/variable/type at a line:column before a rename or to gauge impact. Returns a list of file:line:column locations. Requires the relevant language server on PATH.",
|
||||
schema: referencesSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) =>
|
||||
getReferences(args.path, args.line, args.column, ctx.cwd, args.include_declaration ?? true),
|
||||
};
|
||||
|
||||
const diagnosticsSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("File path, relative to the working directory, to check for type/syntax errors."),
|
||||
});
|
||||
|
||||
export const diagnosticsTool: ToolDef<z.infer<typeof diagnosticsSchema>> = {
|
||||
name: "diagnostics",
|
||||
description:
|
||||
"Get the latest type/syntax diagnostics (errors and warnings) the language server has published for a file — equivalent to an editor's Problems panel. Use right after an edit_file/write_file to verify the change didn't introduce a type error, or when `tsc --noEmit`/`pyright` would be the alternative. Forces a document sync first so the snapshot is current. Returns severity (error/warning/information/hint), line, column, and message for each diagnostic. Requires the relevant language server on PATH.",
|
||||
schema: diagnosticsSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => getDiagnostics(args.path, ctx.cwd),
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { detectEol, editFileTool, fromLF, toLF } from "./editFile.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
describe("editFile tool — path containment", () => {
|
||||
let cwd: string;
|
||||
let ctx: ToolContext;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-editfile-"));
|
||||
ctx = { cwd };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("edits a file inside the working directory", async () => {
|
||||
writeFileSync(path.join(cwd, "note.txt"), "hello world");
|
||||
const result = (await editFileTool.handler({ path: "note.txt", old_string: "world", new_string: "there" }, ctx)) as { replacements: number };
|
||||
expect(result.replacements).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses to edit a file outside the working directory via ../ traversal", async () => {
|
||||
await expect(
|
||||
editFileTool.handler({ path: "../escape.txt", old_string: "a", new_string: "b" }, ctx),
|
||||
).rejects.toThrow(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("preview reports the block instead of reading the target file", async () => {
|
||||
const preview = await editFileTool.preview!({ path: "../escape.txt", old_string: "a", new_string: "b" }, ctx);
|
||||
expect(preview).toMatch(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("handler suggests the closest match when old_string is not found", async () => {
|
||||
writeFileSync(
|
||||
path.join(cwd, "code.ts"),
|
||||
"function greet(name: string): string {\n return `Hello, ${name}!`;\n}\n",
|
||||
);
|
||||
// Close but wrong: single quotes instead of backticks, "Hi" instead of "Hello".
|
||||
await expect(
|
||||
editFileTool.handler(
|
||||
{ path: "code.ts", old_string: "return 'Hi, ${name}!';", new_string: "return `Hi, ${name}!`;" },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/closest match/);
|
||||
});
|
||||
|
||||
it("preview warns and shows the closest match when old_string is not found", async () => {
|
||||
writeFileSync(path.join(cwd, "note.txt"), "the quick brown fox jumps over the lazy dog");
|
||||
const preview = await editFileTool.preview!(
|
||||
{ path: "note.txt", old_string: "the quick red fox jumps over the lazy cat", new_string: "x" },
|
||||
ctx,
|
||||
);
|
||||
expect(preview).toMatch(/not found/);
|
||||
expect(preview).toMatch(/closest match/);
|
||||
expect(preview).toContain("quick brown fox");
|
||||
});
|
||||
|
||||
it("does not suggest a match when nothing is remotely similar", async () => {
|
||||
writeFileSync(path.join(cwd, "note.txt"), "aaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
await expect(
|
||||
editFileTool.handler(
|
||||
{ path: "note.txt", old_string: "completely different text xyz", new_string: "b" },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/not found/);
|
||||
// No "closest match" suffix when similarity is below the threshold.
|
||||
await expect(
|
||||
editFileTool.handler(
|
||||
{ path: "note.txt", old_string: "completely different text xyz", new_string: "b" },
|
||||
ctx,
|
||||
),
|
||||
).rejects.not.toThrow(/closest match/);
|
||||
});
|
||||
|
||||
// read_file shows the model LF-normalized content regardless of the file's real line endings
|
||||
// (see readFile.ts), so old_string/new_string from a model are always LF — matching must happen
|
||||
// in that same space or every CRLF file in a project like this one fails with "not found".
|
||||
it("matches an LF old_string against a CRLF file (mirrors what read_file shows the model)", async () => {
|
||||
writeFileSync(path.join(cwd, "code.ts"), "function greet() {\r\n return 1;\r\n}\r\n");
|
||||
const result = (await editFileTool.handler(
|
||||
{ path: "code.ts", old_string: " return 1;", new_string: " return 2;" },
|
||||
ctx,
|
||||
)) as { replacements: number };
|
||||
expect(result.replacements).toBe(1);
|
||||
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
|
||||
expect(onDisk).toBe("function greet() {\r\n return 2;\r\n}\r\n");
|
||||
});
|
||||
|
||||
it("preserves CRLF line endings on disk after an edit spanning multiple lines", async () => {
|
||||
writeFileSync(path.join(cwd, "code.ts"), "a\r\nb\r\nc\r\n");
|
||||
await editFileTool.handler({ path: "code.ts", old_string: "a\nb", new_string: "a\nx\nb" }, ctx);
|
||||
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
|
||||
expect(onDisk).toBe("a\r\nx\r\nb\r\nc\r\n");
|
||||
});
|
||||
|
||||
it("leaves a pure-LF file untouched by EOL conversion", async () => {
|
||||
writeFileSync(path.join(cwd, "code.ts"), "a\nb\nc\n");
|
||||
await editFileTool.handler({ path: "code.ts", old_string: "b", new_string: "x" }, ctx);
|
||||
const onDisk = readFileSync(path.join(cwd, "code.ts"), "utf-8");
|
||||
expect(onDisk).toBe("a\nx\nc\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EOL helpers", () => {
|
||||
it("detectEol finds CRLF, defaults to LF otherwise", () => {
|
||||
expect(detectEol("a\r\nb")).toBe("\r\n");
|
||||
expect(detectEol("a\nb")).toBe("\n");
|
||||
expect(detectEol("a")).toBe("\n");
|
||||
});
|
||||
|
||||
it("toLF/fromLF round-trip", () => {
|
||||
expect(toLF("a\r\nb\r\nc")).toBe("a\nb\nc");
|
||||
expect(fromLF("a\nb\nc", "\r\n")).toBe("a\r\nb\r\nc");
|
||||
expect(fromLF("a\nb\nc", "\n")).toBe("a\nb\nc");
|
||||
});
|
||||
});
|
||||
+161
-17
@@ -1,8 +1,8 @@
|
||||
import { createPatch } from "diff";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { resolveWithinCwd } from "./pathGuard.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -12,11 +12,31 @@ const schema = z.object({
|
||||
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
|
||||
});
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
/** `read_file` shows the model LF-normalized content (`content.split(/\r?\n/).join(...)` — see
|
||||
* readFile.ts), regardless of the file's actual line endings on disk. A model's `old_string`/
|
||||
* `new_string` are built from what it read, so they're always LF. Matching that against this
|
||||
* tool's raw (real `\r\n`-preserving) file read would fail on every CRLF file in the project —
|
||||
* which is most of them (see the repo's CRLF/LF notes). Detect the file's line ending once, do
|
||||
* all matching/editing in LF space (so `old_string` from the model lines up), then convert the
|
||||
* result back before writing so the file's on-disk convention is preserved rather than silently
|
||||
* flipped to LF. */
|
||||
export function detectEol(raw: string): "\r\n" | "\n" {
|
||||
return raw.includes("\r\n") ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
export function toLF(s: string): string {
|
||||
return s.replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
export function fromLF(s: string, eol: "\r\n" | "\n"): string {
|
||||
return eol === "\n" ? s : s.replace(/\n/g, eol);
|
||||
}
|
||||
|
||||
export function countOccurrences(haystack: string, needle: string): number {
|
||||
return needle === "" ? 0 : haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
|
||||
export function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
|
||||
// Use split/join for both paths instead of String.prototype.replace, whose replacement string
|
||||
// interprets special $-tokens ($$, $&, $`, $', $<name>, $1–$9) even when the *pattern* is a plain
|
||||
// string — which would silently corrupt edits whose replacement text contains a literal "$".
|
||||
@@ -24,37 +44,161 @@ function applyEdit(original: string, oldString: string, newString: string, repla
|
||||
return replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, () => newString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a candidate snippet for fuzzy comparison: collapse runs of whitespace to single spaces
|
||||
* and trim. This makes the similarity score tolerant to indentation/line-ending differences, which
|
||||
* are the most common reasons a local model's old_string almost-matches but not quite.
|
||||
*/
|
||||
function normaliseForCompare(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a Levenshtein distance limited to `maxDist` — early-exits once the distance exceeds it,
|
||||
* making it O(n*m) worst case but far cheaper in practice when we only care about "close enough".
|
||||
*/
|
||||
function boundedLevenshtein(a: string, b: string, maxDist: number): number {
|
||||
const al = a.length;
|
||||
const bl = b.length;
|
||||
if (Math.abs(al - bl) > maxDist) return maxDist + 1;
|
||||
if (al === 0) return bl;
|
||||
if (bl === 0) return al;
|
||||
let prev: number[] = new Array<number>(bl + 1);
|
||||
let curr: number[] = new Array<number>(bl + 1);
|
||||
for (let j = 0; j <= bl; j++) prev[j] = j;
|
||||
for (let i = 1; i <= al; i++) {
|
||||
curr[0] = i;
|
||||
let rowMin = i;
|
||||
const ai = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= bl; j++) {
|
||||
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
const del = (prev[j] ?? 0) + 1;
|
||||
const ins = (curr[j - 1] ?? 0) + 1;
|
||||
const sub = (prev[j - 1] ?? 0) + cost;
|
||||
curr[j] = Math.min(del, ins, sub);
|
||||
const cell = curr[j] ?? 0;
|
||||
if (cell < rowMin) rowMin = cell;
|
||||
}
|
||||
// If every cell in this row already exceeds maxDist, the final answer can only be worse.
|
||||
if (rowMin > maxDist) return maxDist + 1;
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
return prev[bl] ?? maxDist + 1;
|
||||
}
|
||||
|
||||
interface SimilarMatch {
|
||||
/** 0..1 similarity ratio (1 = identical, 0 = unrelated). */
|
||||
score: number;
|
||||
/** The exact text from the file at the best matching window. */
|
||||
snippet: string;
|
||||
/** 1-indexed line number where the snippet starts. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the region of `content` most similar to `needle`. Slides a window of the needle's length
|
||||
* (±50%) across the file in word steps, scoring normalised text with bounded Levenshtein. Returns
|
||||
* the best candidate when its similarity is at least 0.5 — clearly worth suggesting to the model.
|
||||
* Returns null when nothing is close enough, in which case the caller falls back to the plain
|
||||
* "not found" message.
|
||||
*/
|
||||
function findSimilarMatch(content: string, needle: string): SimilarMatch | null {
|
||||
const needleNorm = normaliseForCompare(needle);
|
||||
if (needleNorm.length < 3) return null;
|
||||
|
||||
const words = needleNorm.split(" ");
|
||||
const minLen = Math.floor(needleNorm.length * 0.5);
|
||||
const maxLen = Math.ceil(needleNorm.length * 1.5);
|
||||
|
||||
let best: SimilarMatch | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
// Walk the file by character, treating every position as a potential window start is O(n*len)
|
||||
// and too slow for big files. Instead, step at every Nth character (≈ word boundaries) to keep
|
||||
// it cheap while still landing near real matches.
|
||||
const step = Math.max(1, Math.floor(needleNorm.length / 8));
|
||||
const contentLen = content.length;
|
||||
|
||||
for (let start = 0; start < contentLen; start += step) {
|
||||
for (let len = minLen; len <= maxLen; len += step) {
|
||||
const end = Math.min(start + len, contentLen);
|
||||
const candidate = content.slice(start, end);
|
||||
const candNorm = normaliseForCompare(candidate);
|
||||
if (candNorm.length < minLen) continue;
|
||||
|
||||
// Only spend Levenshtein effort if the lengths are plausibly close.
|
||||
const maxDist = Math.floor(needleNorm.length * 0.5);
|
||||
const dist = boundedLevenshtein(needleNorm, candNorm, maxDist);
|
||||
if (dist >= bestDist) continue;
|
||||
|
||||
bestDist = dist;
|
||||
const score = 1 - dist / Math.max(needleNorm.length, candNorm.length);
|
||||
// 1-indexed line: count newlines before `start`.
|
||||
let line = 1;
|
||||
for (let k = 0; k < start; k++) if (content.charCodeAt(k) === 10) line++;
|
||||
best = { score, snippet: candidate.trim(), line };
|
||||
}
|
||||
}
|
||||
|
||||
if (best && best.score >= 0.5) return best;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build a "did you mean" suffix for error/preview messages. Returns "" if nothing useful. */
|
||||
function similarHint(original: string, oldString: string): string {
|
||||
const m = findSimilarMatch(original, oldString);
|
||||
if (!m) return "";
|
||||
// Truncate long snippets so the message stays readable.
|
||||
const snippet =
|
||||
m.snippet.length > 300 ? `${m.snippet.slice(0, 300)}…` : m.snippet;
|
||||
return `\n\nThe closest match in the file (line ${m.line}, ~${Math.round(m.score * 100)}% similar):\n"""\n${snippet}\n"""\nUse this exact text (or a unique subset of it) as old_string.`;
|
||||
}
|
||||
|
||||
export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Replace exact text in a file. old_string must match exactly. Unless replace_all is set, it must be unique — include enough context.",
|
||||
"Replace exact text in a file. old_string must match exactly. Unless replace_all is set, it must be unique — include enough context. " +
|
||||
"Use for small, targeted changes to an existing file. On a mismatch, the closest similar text is suggested to help retry.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
let original: string;
|
||||
let resolved: string;
|
||||
try {
|
||||
original = await fsReadFile(resolved, "utf-8");
|
||||
resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return `File ${resolved} does not exist.`;
|
||||
}
|
||||
const occurrences = countOccurrences(original, old_string);
|
||||
const eol = detectEol(raw);
|
||||
const original = toLF(raw);
|
||||
const oldLF = toLF(old_string);
|
||||
const newLF = toLF(new_string);
|
||||
const occurrences = countOccurrences(original, oldLF);
|
||||
if (occurrences === 0) {
|
||||
return `Warning: old_string not found in ${resolved} — this edit will fail.`;
|
||||
return `Warning: old_string not found in ${resolved} — this edit will fail.${similarHint(original, oldLF)}`;
|
||||
}
|
||||
if (occurrences > 1 && !replace_all) {
|
||||
return `Warning: old_string appears ${occurrences} times in ${resolved} — this edit will fail unless replace_all is set.`;
|
||||
}
|
||||
const updated = applyEdit(original, old_string, new_string, replace_all);
|
||||
return createPatch(resolved, original, updated, "", "");
|
||||
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
|
||||
return createPatch(resolved, raw, updated, "", "");
|
||||
},
|
||||
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
const original = await fsReadFile(resolved, "utf-8");
|
||||
const occurrences = countOccurrences(original, old_string);
|
||||
const resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
const raw = await fsReadFile(resolved, "utf-8");
|
||||
const eol = detectEol(raw);
|
||||
const original = toLF(raw);
|
||||
const oldLF = toLF(old_string);
|
||||
const newLF = toLF(new_string);
|
||||
const occurrences = countOccurrences(original, oldLF);
|
||||
if (occurrences === 0) {
|
||||
throw new Error(
|
||||
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.`,
|
||||
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.${similarHint(original, oldLF)}`,
|
||||
);
|
||||
}
|
||||
if (occurrences > 1 && !replace_all) {
|
||||
@@ -62,7 +206,7 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
`old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
|
||||
);
|
||||
}
|
||||
const updated = applyEdit(original, old_string, new_string, replace_all);
|
||||
const updated = fromLF(applyEdit(original, oldLF, newLF, replace_all), eol);
|
||||
// Write to a temp file in the same directory, then rename — rename is atomic within a single
|
||||
// directory, so a crash mid-write can't leave the user's source file half-overwritten (the live
|
||||
// file stays intact until the rename swaps in the full new content). Clean up the temp file if
|
||||
@@ -77,4 +221,4 @@ export const editFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
}
|
||||
return { path: resolved, replacements: replace_all ? occurrences : 1 };
|
||||
},
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -14,7 +14,7 @@ const schema = z.object({
|
||||
|
||||
export const grepTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "grep",
|
||||
description: "Search file contents for a regular expression pattern using ripgrep.",
|
||||
description: "Search file contents for a regular expression pattern using ripgrep. Use to find where a symbol/function/word is used across the codebase, or to locate files containing specific text. Faster than reading files one by one.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ pattern, path: searchPath, glob, case_insensitive, max_results }, ctx) => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { agentTool } from "./agentTool.js";
|
||||
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
|
||||
import { bashTool } from "./bash.js";
|
||||
import { bashKillTool } from "./bashKill.js";
|
||||
import { bashOutputTool } from "./bashOutput.js";
|
||||
import { editFileTool } from "./editFile.js";
|
||||
import { multiEditTool } from "./multiEdit.js";
|
||||
import { gitCommitTool, gitStatusTool } from "./git.js";
|
||||
import { grepTool } from "./grep.js";
|
||||
import { listFilesTool } from "./listFiles.js";
|
||||
import { notebookEditTool } from "./notebookEdit.js";
|
||||
import { readFileTool } from "./readFile.js";
|
||||
import { todoWriteTool } from "./todoWrite.js";
|
||||
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool } from "./task.js";
|
||||
import { webFetchTool } from "./webFetch.js";
|
||||
import { webSearchTool } from "./webSearch.js";
|
||||
import { writeFileTool } from "./writeFile.js";
|
||||
@@ -17,16 +21,25 @@ export const TOOLS: ToolDef[] = [
|
||||
readFileTool,
|
||||
listFilesTool,
|
||||
grepTool,
|
||||
definitionTool,
|
||||
referencesTool,
|
||||
diagnosticsTool,
|
||||
webSearchTool,
|
||||
webFetchTool,
|
||||
gitStatusTool,
|
||||
writeFileTool,
|
||||
editFileTool,
|
||||
multiEditTool,
|
||||
notebookEditTool,
|
||||
bashTool,
|
||||
bashOutputTool,
|
||||
bashKillTool,
|
||||
gitCommitTool,
|
||||
todoWriteTool,
|
||||
taskCreateTool,
|
||||
taskListTool,
|
||||
taskGetTool,
|
||||
taskUpdateTool,
|
||||
agentTool,
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const MAX_MATCHES = 500;
|
||||
|
||||
export const listFilesTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "list_files",
|
||||
description: "List files matching a glob pattern.",
|
||||
description: "List files matching a glob pattern (e.g. `src/**/*.ts`). Use to explore the project structure or find files by name/extension before reading them.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ pattern, cwd }, ctx) => {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { multiEditTool } from "./multiEdit.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
describe("multiEdit tool", () => {
|
||||
let cwd: string;
|
||||
let ctx: ToolContext;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-multiedit-"));
|
||||
ctx = { cwd };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("applies an ordered batch to one file in a single atomic write", async () => {
|
||||
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\nconst B = 2;\nconst C = 3;\n");
|
||||
const result = (await multiEditTool.handler(
|
||||
{
|
||||
path: "code.ts",
|
||||
edits: [
|
||||
{ old_string: "const A = 1;", new_string: "const A = 10;" },
|
||||
{ old_string: "const C = 3;", new_string: "const C = 30;" },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)) as { applied: number };
|
||||
expect(result.applied).toBe(2);
|
||||
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
|
||||
"const A = 10;\nconst B = 2;\nconst C = 30;\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("an earlier edit can change the text a later edit matches", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
|
||||
await multiEditTool.handler(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [
|
||||
{ old_string: "alpha", new_string: "beta" },
|
||||
{ old_string: "beta", new_string: "gamma" },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("gamma\n");
|
||||
});
|
||||
|
||||
it("errors on the first edit that doesn't match, naming the edit index", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
|
||||
await expect(
|
||||
multiEditTool.handler(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [
|
||||
{ old_string: "alpha", new_string: "beta" },
|
||||
{ old_string: "missing", new_string: "x" },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/Edit 2: old_string not found/);
|
||||
});
|
||||
|
||||
it("errors when an old_string is ambiguous and replace_all is not set", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
|
||||
await expect(
|
||||
multiEditTool.handler(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [{ old_string: "dup", new_string: "x" }],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/Edit 1: old_string appears 2 times/);
|
||||
});
|
||||
|
||||
it("replace_all applies to all occurrences within the batch step", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
|
||||
await multiEditTool.handler(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [{ old_string: "dup", new_string: "x", replace_all: true }],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("x\nx\n");
|
||||
});
|
||||
|
||||
it("refuses to edit outside the working directory", async () => {
|
||||
await expect(
|
||||
multiEditTool.handler(
|
||||
{ path: "../escape.txt", edits: [{ old_string: "a", new_string: "b" }] },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("preview produces a unified diff of the full batch", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "one\ntwo\n");
|
||||
const preview = await multiEditTool.preview!(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [
|
||||
{ old_string: "one", new_string: "ONE" },
|
||||
{ old_string: "two", new_string: "TWO" },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(preview).toMatch(/-one/);
|
||||
expect(preview).toMatch(/\+ONE/);
|
||||
expect(preview).toMatch(/-two/);
|
||||
expect(preview).toMatch(/\+TWO/);
|
||||
});
|
||||
|
||||
it("preview warns when a batch edit will fail", async () => {
|
||||
writeFileSync(path.join(cwd, "f.txt"), "one\n");
|
||||
const preview = await multiEditTool.preview!(
|
||||
{
|
||||
path: "f.txt",
|
||||
edits: [{ old_string: "missing", new_string: "x" }],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(preview).toMatch(/Edit 1: old_string not found/);
|
||||
});
|
||||
|
||||
// Same LF-vs-CRLF mismatch as editFile.test.ts: read_file always shows the model LF content, so
|
||||
// a batch's old_string/new_string must match against a CRLF file's LF-normalized text, and the
|
||||
// result written back must preserve the file's original CRLF convention.
|
||||
it("matches LF old_strings against a CRLF file and preserves CRLF on write", async () => {
|
||||
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\r\nconst B = 2;\r\nconst C = 3;\r\n");
|
||||
await multiEditTool.handler(
|
||||
{
|
||||
path: "code.ts",
|
||||
edits: [
|
||||
{ old_string: "const A = 1;", new_string: "const A = 10;" },
|
||||
{ old_string: "const C = 3;", new_string: "const C = 30;" },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
|
||||
"const A = 10;\r\nconst B = 2;\r\nconst C = 30;\r\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createPatch } from "diff";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { resolveWithinCwd } from "./pathGuard.js";
|
||||
import { applyEdit, countOccurrences, detectEol, fromLF, toLF } from "./editFile.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
// A single edit within a multi_edit batch. Mirrors edit_file's args minus `path` (which is shared
|
||||
// across the whole batch). Each edit is applied in array order to the result of the previous one, so
|
||||
// an earlier edit can shift the text a later edit matches — that's why each old_string is checked
|
||||
// against the running result, not the original file.
|
||||
const editSchema = z.object({
|
||||
old_string: z.string().describe("Exact text to replace. Must match the current file content exactly at this point in the batch — earlier edits may have shifted it."),
|
||||
new_string: z.string().describe("Replacement text."),
|
||||
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
path: z.string().describe("File path to edit, relative to the working directory or absolute."),
|
||||
edits: z.array(editSchema).min(1).describe("Ordered list of edits to apply to the same file, one after another. Each edit sees the result of the previous one."),
|
||||
});
|
||||
|
||||
/** Applies a batch of edits to an in-memory string, validating each. Throws on the first edit that
|
||||
* doesn't match uniquely (unless its replace_all is set) or doesn't match at all. Edits apply to the
|
||||
* running result, so an earlier edit can change the text a later edit matches. `original` and every
|
||||
* edit's old_string/new_string must already be LF-normalized (see editFile.ts's detectEol/toLF —
|
||||
* read_file shows the model LF-only content regardless of the file's real line endings, so matching
|
||||
* must happen in that same space). */
|
||||
function applyBatch(
|
||||
original: string,
|
||||
edits: { old_string: string; new_string: string; replace_all?: boolean }[],
|
||||
filePath: string,
|
||||
): string {
|
||||
let current = original;
|
||||
edits.forEach((edit, i) => {
|
||||
const oldLF = toLF(edit.old_string);
|
||||
const occurrences = countOccurrences(current, oldLF);
|
||||
if (occurrences === 0) {
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: old_string not found in ${filePath}. Earlier edits may have shifted the text — re-read the file and adjust. Make sure it matches exactly, including whitespace.`,
|
||||
);
|
||||
}
|
||||
if (occurrences > 1 && !edit.replace_all) {
|
||||
throw new Error(
|
||||
`Edit ${i + 1}: old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
|
||||
);
|
||||
}
|
||||
current = applyEdit(current, oldLF, toLF(edit.new_string), edit.replace_all);
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
export const multiEditTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "multi_edit",
|
||||
description:
|
||||
"Apply several edits to the same file in one call, in order. Each edit is {old_string, new_string, replace_all?}. " +
|
||||
"Use this instead of repeated edit_file calls when you have multiple distinct changes to one file — it's one confirmation " +
|
||||
"and one atomic write instead of N round-trips. Each old_string must match uniquely at its point in the batch " +
|
||||
"(unless replace_all is set). Read the file first.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, edits }, ctx) => {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return `File ${resolved} does not exist.`;
|
||||
}
|
||||
try {
|
||||
const eol = detectEol(raw);
|
||||
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
|
||||
return createPatch(resolved, raw, updated, "", "");
|
||||
} catch (err) {
|
||||
return `Warning: ${(err as Error).message} — this edit will fail.`;
|
||||
}
|
||||
},
|
||||
handler: async ({ path: filePath, edits }, ctx) => {
|
||||
const resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
const raw = await fsReadFile(resolved, "utf-8");
|
||||
const eol = detectEol(raw);
|
||||
const updated = fromLF(applyBatch(toLF(raw), edits, filePath), eol);
|
||||
// Atomic write via temp+rename (same rationale as edit_file): a crash mid-write can't leave the
|
||||
// user's source file half-overwritten — the live file stays intact until the rename swaps in the
|
||||
// full new content. Clean up the temp file if anything fails so a stray `.tmp` doesn't accumulate.
|
||||
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
await fsWriteFile(tmp, updated, "utf-8");
|
||||
await fsRename(tmp, resolved);
|
||||
} catch (err) {
|
||||
await fsUnlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return { path: resolved, applied: edits.length };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { notebookEditTool } from "./notebookEdit.js";
|
||||
|
||||
/** A minimal valid nbformat 4 notebook with two code cells. */
|
||||
function minimalNotebook(): string {
|
||||
return (
|
||||
JSON.stringify(
|
||||
{
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
metadata: {},
|
||||
cells: [
|
||||
{ cell_type: "code", id: "c1", source: ["print('a')\n"], metadata: {}, outputs: [], execution_count: null },
|
||||
{ cell_type: "code", id: "c2", source: ["print('b')\n"], metadata: {}, outputs: [], execution_count: null },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
async function makeCwd(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "locode-notebook-"));
|
||||
}
|
||||
|
||||
function parseCells(content: string): { cell_type: string; id?: string; source: string[] }[] {
|
||||
return (JSON.parse(content) as { cells: { cell_type: string; id?: string; source: string[] }[] }).cells;
|
||||
}
|
||||
|
||||
describe("notebookEditTool", () => {
|
||||
it("rejects paths that escape the working directory", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "../outside.ipynb", edit_mode: "delete" }, { cwd }),
|
||||
).rejects.toThrow(/outside the working directory/);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a cell source by cell_index", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells[0]!.source).toEqual(["print('A')\n"]);
|
||||
expect(cells).toHaveLength(2);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a cell source by cell_id", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_id: "c2", edit_mode: "replace", new_source: "print('B2')" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells[1]!.source).toEqual(["print('B2')"]);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts a new markdown cell at a position, shifting later cells down", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_index: 1, cell_type: "markdown", new_source: "# heading\n\ntext" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(cells[1]!.cell_type).toBe("markdown");
|
||||
expect(cells[1]!.source).toEqual(["# heading\n", "\n", "text"]);
|
||||
// Original second cell (c2) is now at index 2.
|
||||
expect(cells[2]!.id).toBe("c2");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends a cell when insert omits cell_index", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", edit_mode: "insert", cell_type: "code", new_source: "x = 1" },
|
||||
{ cwd },
|
||||
);
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(cells[2]!.source).toEqual(["x = 1"]);
|
||||
expect(cells[2]!.cell_type).toBe("code");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes a cell by cell_id, and a missing id fails", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "c1", edit_mode: "delete" }, { cwd });
|
||||
const cells = parseCells(await readFile(file, "utf-8"));
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells[0]!.id).toBe("c2");
|
||||
|
||||
// A missing id fails.
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_id: "nope", edit_mode: "delete" }, { cwd }),
|
||||
).rejects.toThrow(/not found/);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects insert without cell_type, and replace without new_source", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", edit_mode: "insert", new_source: "x" }, { cwd }),
|
||||
).rejects.toThrow(/cell_type/);
|
||||
await expect(
|
||||
notebookEditTool.handler({ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace" }, { cwd }),
|
||||
).rejects.toThrow(/new_source/);
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("produces a diff preview for an edit", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
const preview = await notebookEditTool.preview!(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", new_source: "print('A')\n" },
|
||||
{ cwd },
|
||||
);
|
||||
expect(preview).toContain("@@");
|
||||
expect(preview).toContain("print('A')");
|
||||
expect(preview).toContain("print('a')");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("switching a code cell to markdown drops code-only fields", async () => {
|
||||
const cwd = await makeCwd();
|
||||
try {
|
||||
const file = path.join(cwd, "nb.ipynb");
|
||||
await writeFile(file, minimalNotebook());
|
||||
await notebookEditTool.handler(
|
||||
{ notebook_path: "nb.ipynb", cell_index: 0, edit_mode: "replace", cell_type: "markdown", new_source: "prose" },
|
||||
{ cwd },
|
||||
);
|
||||
const cell = parseCells(await readFile(file, "utf-8"))[0]!;
|
||||
expect(cell.cell_type).toBe("markdown");
|
||||
expect(cell).not.toHaveProperty("execution_count");
|
||||
expect(cell).not.toHaveProperty("outputs");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createPatch } from "diff";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { resolveWithinCwd } from "./pathGuard.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
// .ipynb is a JSON document (nbformat 4): { nbformat, nbformat_minor, metadata, cells: Cell[] }.
|
||||
// Each cell is { cell_type: "code"|"markdown"|"raw", id?, source, metadata, outputs?, execution_count? }.
|
||||
// `source` is a list of strings where every line except the last carries a trailing "\n" (nbformat
|
||||
// convention). We convert the model's single-string new_source to/from that array form, so the
|
||||
// model never has to hand-write nbformat's line-array quirk — it just gives the full cell text.
|
||||
|
||||
type Notebook = { nbformat: number; nbformat_minor: number; metadata: Record<string, unknown>; cells: Cell[] };
|
||||
type Cell = { cell_type: string; id?: string; source: string[]; metadata: Record<string, unknown>; outputs?: unknown[]; execution_count?: unknown };
|
||||
|
||||
const schema = z.object({
|
||||
notebook_path: z.string().describe("Path to the .ipynb notebook to edit, relative to the working directory or absolute."),
|
||||
cell_id: z.string().optional().describe("The id of the cell to replace or delete. Ignored for insert."),
|
||||
cell_index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("0-based index of the cell to replace or delete; for insert, the position to insert at (defaults to append)."),
|
||||
cell_type: z.enum(["code", "markdown", "raw"]).optional().describe("Required for insert. For replace, overrides the existing cell's type if given."),
|
||||
edit_mode: z.enum(["replace", "insert", "delete"]).default("replace").describe("Whether to replace a cell, insert a new one, or delete."),
|
||||
new_source: z.string().optional().describe("The new cell source as a single string (required for replace and insert)."),
|
||||
});
|
||||
|
||||
/** Converts a plain multi-line string into nbformat's source array: each line carries a trailing
|
||||
* "\n" except the last, and a trailing newline in the input is preserved (so "a\n" => ["a\n"], not
|
||||
* ["a\n", ""]). An empty source becomes an empty array. Round-trips: array.join("") === input. */
|
||||
function toSourceArray(source: string): string[] {
|
||||
if (!source) return [];
|
||||
let lines = source.split("\n");
|
||||
// split("a\n") => ["a", ""] — the trailing "" is an artifact of the trailing newline, not a real
|
||||
// empty last line. Drop it and remember the input ended with \n so the now-last line keeps its \n.
|
||||
const endedWithNewline = lines.length > 1 && lines[lines.length - 1] === "";
|
||||
if (endedWithNewline) lines = lines.slice(0, -1);
|
||||
return lines.map((line, i) => (i < lines.length - 1 || endedWithNewline ? line + "\n" : line));
|
||||
}
|
||||
|
||||
/** Finds a cell's index by id (if present) falling back to the explicit index. Returns -1 if not
|
||||
* found. Used by replace/delete. */
|
||||
function findCellIndex(notebook: Notebook, cellId: string | undefined, cellIndex: number | undefined): number {
|
||||
if (cellId !== undefined) {
|
||||
return notebook.cells.findIndex((c) => c.id === cellId);
|
||||
}
|
||||
if (cellIndex !== undefined) {
|
||||
return cellIndex >= 0 && cellIndex < notebook.cells.length ? cellIndex : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function applyNotebookEdit(notebook: Notebook, args: z.infer<typeof schema>, notebookPath: string): Notebook {
|
||||
const mode = args.edit_mode ?? "replace";
|
||||
if (mode === "insert") {
|
||||
if (!args.cell_type) throw new Error("insert requires 'cell_type'.");
|
||||
if (args.new_source === undefined) throw new Error("insert requires 'new_source'.");
|
||||
const cell: Cell = {
|
||||
cell_type: args.cell_type,
|
||||
source: toSourceArray(args.new_source),
|
||||
metadata: {},
|
||||
};
|
||||
if (args.cell_type === "code") {
|
||||
cell.execution_count = null;
|
||||
cell.outputs = [];
|
||||
}
|
||||
const insertAt = args.cell_index ?? notebook.cells.length;
|
||||
if (insertAt < 0 || insertAt > notebook.cells.length) {
|
||||
throw new Error(`insert cell_index ${insertAt} is out of range (0–${notebook.cells.length}).`);
|
||||
}
|
||||
notebook.cells.splice(insertAt, 0, cell);
|
||||
return notebook;
|
||||
}
|
||||
|
||||
const idx = findCellIndex(notebook, args.cell_id, args.cell_index);
|
||||
if (idx === -1) {
|
||||
const where = args.cell_id !== undefined ? `cell_id "${args.cell_id}"` : `cell_index ${args.cell_index}`;
|
||||
throw new Error(`${mode}: ${where} not found in ${notebookPath}.`);
|
||||
}
|
||||
if (mode === "delete") {
|
||||
notebook.cells.splice(idx, 1);
|
||||
return notebook;
|
||||
}
|
||||
// replace
|
||||
if (args.new_source === undefined) throw new Error("replace requires 'new_source'.");
|
||||
const cell = notebook.cells[idx]!;
|
||||
if (args.cell_type) cell.cell_type = args.cell_type;
|
||||
cell.source = toSourceArray(args.new_source);
|
||||
// Switching to a non-code cell type drops code-only fields; switching to code adds them.
|
||||
if (cell.cell_type === "code") {
|
||||
cell.execution_count ??= null;
|
||||
cell.outputs ??= [];
|
||||
} else {
|
||||
delete cell.execution_count;
|
||||
delete cell.outputs;
|
||||
}
|
||||
return notebook;
|
||||
}
|
||||
|
||||
export const notebookEditTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "notebook_edit",
|
||||
description:
|
||||
"Edit a Jupyter (.ipynb) notebook cell-aware: replace, insert, or delete a cell by cell_id or cell_index. " +
|
||||
"new_source is the full new cell source as a single string. Read the notebook first (read_file shows the JSON). " +
|
||||
"Prefer this over edit_file/write_file for .ipynb so the JSON structure stays valid.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async (args, ctx) => {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveWithinCwd(ctx.cwd, args.notebook_path);
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
let original: string;
|
||||
try {
|
||||
original = await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return `Notebook ${resolved} does not exist.`;
|
||||
}
|
||||
let notebook: Notebook;
|
||||
try {
|
||||
notebook = JSON.parse(original) as Notebook;
|
||||
} catch {
|
||||
return `Warning: ${resolved} is not valid JSON — this edit will fail.`;
|
||||
}
|
||||
try {
|
||||
const updated = applyNotebookEdit(structuredClone(notebook), args, args.notebook_path);
|
||||
return createPatch(resolved, original, JSON.stringify(updated, null, 2) + "\n", "", "");
|
||||
} catch (err) {
|
||||
return `Warning: ${(err as Error).message} — this edit will fail.`;
|
||||
}
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const resolved = resolveWithinCwd(ctx.cwd, args.notebook_path);
|
||||
const original = await fsReadFile(resolved, "utf-8");
|
||||
let notebook: Notebook;
|
||||
try {
|
||||
notebook = JSON.parse(original) as Notebook;
|
||||
} catch {
|
||||
throw new Error(`${resolved} is not valid JSON — can't edit as a notebook.`);
|
||||
}
|
||||
if (!Array.isArray(notebook.cells)) throw new Error(`${resolved} has no cells array — not a valid .ipynb.`);
|
||||
applyNotebookEdit(notebook, args, args.notebook_path);
|
||||
const updated = JSON.stringify(notebook, null, 2) + "\n";
|
||||
// Atomic write via temp+rename (same rationale as edit_file/multi_edit): a crash mid-write can't
|
||||
// leave the notebook half-overwritten. Clean up the temp file if anything fails.
|
||||
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
|
||||
try {
|
||||
await fsWriteFile(tmp, updated, "utf-8");
|
||||
await fsRename(tmp, resolved);
|
||||
} catch (err) {
|
||||
await fsUnlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return { path: resolved, edit_mode: args.edit_mode ?? "replace", cell_count: notebook.cells.length };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PathOutsideCwdError, resolveWithinCwd } from "./pathGuard.js";
|
||||
|
||||
describe("resolveWithinCwd", () => {
|
||||
const cwd = path.resolve("/project");
|
||||
|
||||
it("resolves a plain relative path inside cwd", () => {
|
||||
expect(resolveWithinCwd(cwd, "src/index.ts")).toBe(path.join(cwd, "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("resolves an absolute path that happens to already be inside cwd", () => {
|
||||
const inside = path.join(cwd, "foo.txt");
|
||||
expect(resolveWithinCwd(cwd, inside)).toBe(inside);
|
||||
});
|
||||
|
||||
it("resolves cwd itself", () => {
|
||||
expect(resolveWithinCwd(cwd, ".")).toBe(cwd);
|
||||
});
|
||||
|
||||
it("rejects a ../ escape", () => {
|
||||
expect(() => resolveWithinCwd(cwd, "../outside.txt")).toThrow(PathOutsideCwdError);
|
||||
});
|
||||
|
||||
it("rejects a deeper ../../ escape", () => {
|
||||
expect(() => resolveWithinCwd(cwd, "sub/../../outside.txt")).toThrow(PathOutsideCwdError);
|
||||
});
|
||||
|
||||
it("rejects an absolute path outside cwd", () => {
|
||||
expect(() => resolveWithinCwd(cwd, path.resolve("/etc/passwd"))).toThrow(PathOutsideCwdError);
|
||||
});
|
||||
|
||||
it("rejects the filesystem root", () => {
|
||||
expect(() => resolveWithinCwd(cwd, path.parse(cwd).root)).toThrow(PathOutsideCwdError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** Thrown by resolveWithinCwd — kept as its own class only so callers can recognize it (via
|
||||
* instanceof) if they ever need to react differently than a plain thrown Error. */
|
||||
export class PathOutsideCwdError extends Error {}
|
||||
|
||||
/** Resolves `targetPath` against `cwd` and hard-blocks the result if it would land outside the
|
||||
* project root (the working directory locode was launched in) — an absolute path elsewhere on
|
||||
* disk, a `../` escape, or (on Windows) a path on a different drive all reject. This applies
|
||||
* unconditionally, regardless of permission mode: even `auto-accept` skips a tool's `preview`
|
||||
* entirely (see gateAndRun in agent/loop.ts), so this check has to live in each tool's `handler`
|
||||
* — which always runs — to actually hold as a floor rather than just a confirmation-dialog hint.
|
||||
* It's deliberately not configurable; a model tricked (or simply mistaken) into targeting a path
|
||||
* outside the project shouldn't be one auto-approved call away from touching it. */
|
||||
export function resolveWithinCwd(cwd: string, targetPath: string): string {
|
||||
const resolved = path.resolve(cwd, targetPath);
|
||||
const rel = path.relative(cwd, resolved);
|
||||
// rel === "" is targetPath resolving to cwd itself — fine. Anything starting with ".." walked
|
||||
// upward out of cwd; an absolute rel (Windows: a different drive, e.g. "D:\foo") never went
|
||||
// through cwd's tree in the first place. Either way, it's outside.
|
||||
if (rel !== "" && (rel.startsWith(`..${path.sep}`) || rel === ".." || path.isAbsolute(rel))) {
|
||||
throw new PathOutsideCwdError(
|
||||
`Refusing to write outside the working directory: "${targetPath}" resolves to ${resolved}, which is not inside ${cwd}.`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
+40
-3
@@ -9,6 +9,18 @@ function withinWorkspace(resolved: string, workspace: string): boolean {
|
||||
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// Reject files larger than this so we never accidentally OOM on a huge binary or log file.
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Common binary extensions — if the file extension matches, reject without reading.
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".o", ".obj", ".pyc", ".pyo",
|
||||
".class", ".jar", ".war", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
||||
".iso", ".dmg", ".pdb", ".lib", ".a", ".woff", ".woff2", ".eot", ".ttf", ".otf",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".sqlite", ".db", ".ico", ".cur",
|
||||
]);
|
||||
|
||||
// Cap on how much text a single read_file call returns, so a huge file can't blow up the context
|
||||
// in one call. Cut on a line boundary (never mid-line) and report the exact next offset, so the
|
||||
// model can page through the rest with `offset` instead of re-reading the same truncated prefix in
|
||||
@@ -26,7 +38,8 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a local file. Text files return 1-indexed lines; large files are paginated (use nextOffset for next page). " +
|
||||
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model).",
|
||||
"Image files (png, jpg, jpeg, gif, webp, bmp) are returned as image content (requires vision-capable model). " +
|
||||
"Use to inspect file contents before editing, or to understand existing code. Prefer this over bash cat for files.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ path: filePath, offset, limit }, ctx) => {
|
||||
@@ -35,9 +48,16 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
throw new Error(`File ${filePath} resolves outside the workspace.`);
|
||||
}
|
||||
|
||||
// --- Size guard: reject files over MAX_FILE_SIZE before reading ---
|
||||
const stats = await fsStat(resolved);
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
throw new Error(
|
||||
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_FILE_SIZE / 1_048_576}MB read limit.`,
|
||||
);
|
||||
}
|
||||
|
||||
const mimeType = imageMimeType(resolved);
|
||||
if (mimeType) {
|
||||
const stats = await fsStat(resolved);
|
||||
if (stats.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(
|
||||
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1_048_576}MB limit for image reads.`,
|
||||
@@ -47,8 +67,25 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
return { path: resolved, image: true, mimeType, bytes: buffer.byteLength, base64: buffer.toString("base64") };
|
||||
}
|
||||
|
||||
// --- Binary guard: reject by extension ---
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (BINARY_EXTENSIONS.has(ext)) {
|
||||
throw new Error(
|
||||
`${filePath} looks like a binary file (${ext}). Use bash for binary inspection.`,
|
||||
);
|
||||
}
|
||||
|
||||
const content = await fsReadFile(resolved, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// --- Binary guard: null-byte heuristic (catches extensionless binaries) ---
|
||||
const nullIndex = content.indexOf("\0");
|
||||
if (nullIndex !== -1) {
|
||||
throw new Error(
|
||||
`${filePath} appears to be a binary file (null byte at position ${nullIndex}). Use bash for binary inspection.`,
|
||||
);
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const start = offset ? offset - 1 : 0;
|
||||
const requestedEnd = limit ? Math.min(start + limit, lines.length) : lines.length;
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool, TaskStore } from "./task.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
function ctxWithStore(): { ctx: ToolContext; store: TaskStore } {
|
||||
const store = new TaskStore();
|
||||
return { ctx: { taskStore: store } as ToolContext, store };
|
||||
}
|
||||
|
||||
describe("task tools", () => {
|
||||
it("task_create creates a pending task and returns it with an id", async () => {
|
||||
const { ctx, store } = ctxWithStore();
|
||||
const out = (await taskCreateTool.handler({ subject: "Fix bug", description: "details" }, ctx)) as {
|
||||
id: string;
|
||||
task: { status: string; blocks: string[]; blockedBy: string[] };
|
||||
};
|
||||
expect(out.id).toMatch(/^t\d+$/);
|
||||
expect(out.task.status).toBe("pending");
|
||||
expect(out.task.blocks).toEqual([]);
|
||||
expect(out.task.blockedBy).toEqual([]);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("task_list returns all task summaries", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
await taskCreateTool.handler({ subject: "A", description: "x" }, ctx);
|
||||
await taskCreateTool.handler({ subject: "B", description: "y" }, ctx);
|
||||
const out = (await taskListTool.handler({}, ctx)) as { tasks: { subject: string }[] };
|
||||
expect(out.tasks.map((t) => t.subject)).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("task_get returns full details and errors on unknown id", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const created = (await taskCreateTool.handler({ subject: "A", description: "long desc" }, ctx)) as {
|
||||
id: string;
|
||||
task: { description: string };
|
||||
};
|
||||
const got = (await taskGetTool.handler({ taskId: created.id }, ctx)) as { task: { description: string } };
|
||||
expect(got.task.description).toBe("long desc");
|
||||
const miss = (await taskGetTool.handler({ taskId: "nope" }, ctx)) as { error: string };
|
||||
expect(miss.error).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it("task_update sets status and marks in_progress/completed", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const created = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||
const upd = (await taskUpdateTool.handler({ taskId: created.id, status: "in_progress" }, ctx)) as {
|
||||
task: { status: string };
|
||||
};
|
||||
expect(upd.task.status).toBe("in_progress");
|
||||
const done = (await taskUpdateTool.handler({ taskId: created.id, status: "completed" }, ctx)) as {
|
||||
task: { status: string };
|
||||
};
|
||||
expect(done.task.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("addBlocks/addBlockedBy link two tasks both ways", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||
// B is blocked by A (one-directional: sets B.blockedBy, not A.blocks)
|
||||
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
|
||||
expect(bAfter.task.blockedBy).toContain(a.id);
|
||||
// Add the back-ref explicitly: A blocks B.
|
||||
await taskUpdateTool.handler({ taskId: a.id, addBlocks: [b.id] }, ctx);
|
||||
const aAfter = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blocks: string[] } };
|
||||
expect(aAfter.task.blocks).toContain(b.id);
|
||||
});
|
||||
|
||||
it("ignores self-refs, unknown ids, and direct 2-cycles", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||
// self-ref ignored
|
||||
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [a.id] }, ctx);
|
||||
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||
// unknown id ignored
|
||||
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: ["zzz"] }, ctx);
|
||||
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||
// B waits on A; now make A wait on B — should be skipped (2-cycle)
|
||||
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [b.id] }, ctx);
|
||||
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
|
||||
});
|
||||
|
||||
it("status deleted removes the task and prunes dangling refs", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
|
||||
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
|
||||
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
|
||||
const del = (await taskUpdateTool.handler({ taskId: a.id, status: "deleted" }, ctx)) as { deleted: string };
|
||||
expect(del.deleted).toBe(a.id);
|
||||
// B no longer blocked by the removed A
|
||||
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
|
||||
expect(bAfter.task.blockedBy).toEqual([]);
|
||||
expect(((await taskListTool.handler({}, ctx)) as { tasks: unknown[] }).tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("metadata merge-patch: set keys, null deletes", async () => {
|
||||
const { ctx } = ctxWithStore();
|
||||
const a = (await taskCreateTool.handler({ subject: "A", description: "x", metadata: { k: 1 } }, ctx)) as { id: string };
|
||||
await taskUpdateTool.handler({ taskId: a.id, metadata: { k2: "v" } }, ctx);
|
||||
let t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
|
||||
expect(t.task.metadata).toEqual({ k: 1, k2: "v" });
|
||||
await taskUpdateTool.handler({ taskId: a.id, metadata: { k: null } }, ctx);
|
||||
t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
|
||||
expect(t.task.metadata).toEqual({ k2: "v" });
|
||||
});
|
||||
|
||||
it("returns an error when taskStore is absent", async () => {
|
||||
const ctx = {} as ToolContext;
|
||||
const out = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { error: string };
|
||||
expect(out.error).toMatch(/not available/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { z } from "zod";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is
|
||||
* never a stored status. */
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy`
|
||||
* (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form
|
||||
* metadata. Unlike the flat todo list, tasks are created and updated incrementally (not replaced
|
||||
* wholesale) so dependencies and ownership can be expressed. */
|
||||
export interface Task {
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
/** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */
|
||||
activeForm?: string;
|
||||
status: TaskStatus;
|
||||
/** Who has claimed the task (an agent name). Unset = unclaimed. */
|
||||
owner?: string;
|
||||
/** Ids of tasks THIS task blocks (i.e. that depend on it). */
|
||||
blocks: string[];
|
||||
/** Ids of tasks that must be completed before this one can start. */
|
||||
blockedBy: string[];
|
||||
/** Free-form metadata; merge-patched on update (set a key to null to delete it). */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A compact, list-view projection of a Task — enough to render the checklist without the long
|
||||
* description/metadata. */
|
||||
export interface TaskSummary {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: TaskStatus;
|
||||
owner?: string;
|
||||
blockedBy: string[];
|
||||
}
|
||||
|
||||
/** JSON-serializable shape for persisting a TaskStore. */
|
||||
export interface TaskStoreSnapshot {
|
||||
seq: number;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
|
||||
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
|
||||
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
|
||||
export class TaskStore {
|
||||
private tasks = new Map<string, Task>();
|
||||
private seq = 0;
|
||||
private emitter: ((tasks: TaskSummary[]) => void) | undefined;
|
||||
|
||||
/** Wired by the host so the store can broadcast a snapshot after each mutation. */
|
||||
setEmitter(emit: (tasks: TaskSummary[]) => void): void {
|
||||
this.emitter = emit;
|
||||
}
|
||||
|
||||
private nextId(): string {
|
||||
this.seq += 1;
|
||||
return `t${this.seq}`;
|
||||
}
|
||||
|
||||
private snapshot(): TaskSummary[] {
|
||||
return [...this.tasks.values()].map((t) => ({
|
||||
id: t.id,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
owner: t.owner,
|
||||
blockedBy: [...t.blockedBy],
|
||||
}));
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.emitter?.(this.snapshot());
|
||||
}
|
||||
|
||||
create(input: {
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Task {
|
||||
const task: Task = {
|
||||
id: this.nextId(),
|
||||
subject: input.subject,
|
||||
description: input.description,
|
||||
activeForm: input.activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: input.metadata ? { ...input.metadata } : undefined,
|
||||
};
|
||||
this.tasks.set(task.id, task);
|
||||
this.emit();
|
||||
return task;
|
||||
}
|
||||
|
||||
list(): TaskSummary[] {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
get(id: string): Task | undefined {
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
|
||||
/** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy
|
||||
* refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */
|
||||
update(
|
||||
id: string,
|
||||
updates: {
|
||||
status?: TaskStatus | "deleted";
|
||||
subject?: string;
|
||||
description?: string;
|
||||
activeForm?: string;
|
||||
owner?: string;
|
||||
addBlocks?: string[];
|
||||
addBlockedBy?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Task | undefined {
|
||||
const task = this.tasks.get(id);
|
||||
if (!task) return undefined;
|
||||
if (updates.status === "deleted") {
|
||||
this.remove(id);
|
||||
return undefined;
|
||||
}
|
||||
if (updates.status) task.status = updates.status;
|
||||
if (updates.subject !== undefined) task.subject = updates.subject;
|
||||
if (updates.description !== undefined) task.description = updates.description;
|
||||
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
|
||||
if (updates.owner !== undefined) task.owner = updates.owner;
|
||||
if (updates.addBlocks) {
|
||||
for (const b of updates.addBlocks) {
|
||||
// Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates.
|
||||
if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b);
|
||||
}
|
||||
}
|
||||
if (updates.addBlockedBy) {
|
||||
for (const b of updates.addBlockedBy) {
|
||||
if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue;
|
||||
// Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other.
|
||||
const other = this.tasks.get(b);
|
||||
if (other && other.blockedBy.includes(id)) continue;
|
||||
task.blockedBy.push(b);
|
||||
}
|
||||
}
|
||||
if (updates.metadata) {
|
||||
task.metadata = mergeMetadata(task.metadata, updates.metadata);
|
||||
}
|
||||
this.emit();
|
||||
return task;
|
||||
}
|
||||
|
||||
private remove(id: string): void {
|
||||
this.tasks.delete(id);
|
||||
// Prune dangling dependency refs in surviving tasks.
|
||||
for (const other of this.tasks.values()) {
|
||||
other.blocks = other.blocks.filter((b) => b !== id);
|
||||
other.blockedBy = other.blockedBy.filter((b) => b !== id);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Serialize the entire store for persistence. */
|
||||
toJSON(): TaskStoreSnapshot {
|
||||
return {
|
||||
seq: this.seq,
|
||||
tasks: [...this.tasks.values()].map(serializeTask),
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore a store from a previously-serialized snapshot. */
|
||||
static fromJSON(snapshot: TaskStoreSnapshot): TaskStore {
|
||||
const store = new TaskStore();
|
||||
store.seq = snapshot.seq;
|
||||
for (const task of snapshot.tasks) {
|
||||
store.tasks.set(task.id, { ...task, blocks: [...task.blocks], blockedBy: [...task.blockedBy], metadata: task.metadata ? { ...task.metadata } : undefined });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
|
||||
function mergeMetadata(
|
||||
existing: Record<string, unknown> | undefined,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const out: Record<string, unknown> = { ...(existing ?? {}) };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null) delete out[k];
|
||||
else out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller
|
||||
* mutate the store's internal object. */
|
||||
function serializeTask(t: Task): Task {
|
||||
return {
|
||||
...t,
|
||||
blocks: [...t.blocks],
|
||||
blockedBy: [...t.blockedBy],
|
||||
metadata: t.metadata ? { ...t.metadata } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataSchema = z.record(z.string(), z.any()).optional();
|
||||
|
||||
const taskCreateSchema = z.object({
|
||||
subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."),
|
||||
description: z.string().describe("What needs to be done, in enough detail to act on."),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."),
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
export const taskCreateTool: ToolDef<z.infer<typeof taskCreateSchema>> = {
|
||||
name: "task_create",
|
||||
description:
|
||||
"Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " +
|
||||
"visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " +
|
||||
"task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.",
|
||||
schema: taskCreateSchema,
|
||||
// Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt.
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.taskStore) return { error: "Task tracking is not available in this context." };
|
||||
const task = ctx.taskStore.create(args);
|
||||
return { id: task.id, task: serializeTask(task) };
|
||||
},
|
||||
};
|
||||
|
||||
const taskListSchema = z.object({});
|
||||
|
||||
export const taskListTool: ToolDef<z.infer<typeof taskListSchema>> = {
|
||||
name: "task_list",
|
||||
description:
|
||||
"List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " +
|
||||
"find the next available task to claim.",
|
||||
schema: taskListSchema,
|
||||
mutating: false,
|
||||
handler: async (_args, ctx) => {
|
||||
return { tasks: ctx.taskStore?.list() ?? [] };
|
||||
},
|
||||
};
|
||||
|
||||
const taskGetSchema = z.object({ taskId: z.string().min(1) });
|
||||
|
||||
export const taskGetTool: ToolDef<z.infer<typeof taskGetSchema>> = {
|
||||
name: "task_get",
|
||||
description:
|
||||
"Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " +
|
||||
"verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.",
|
||||
schema: taskGetSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const task = ctx.taskStore?.get(args.taskId);
|
||||
return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` };
|
||||
},
|
||||
};
|
||||
|
||||
const taskUpdateSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
addBlocks: z.array(z.string()).optional(),
|
||||
addBlockedBy: z.array(z.string()).optional(),
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
export const taskUpdateTool: ToolDef<z.infer<typeof taskUpdateSchema>> = {
|
||||
name: "task_update",
|
||||
description:
|
||||
"Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " +
|
||||
"set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " +
|
||||
"to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " +
|
||||
"before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.",
|
||||
schema: taskUpdateSchema,
|
||||
mutating: false,
|
||||
handler: async (args, ctx) => {
|
||||
const store = ctx.taskStore;
|
||||
if (!store) return { error: "Task tracking is not available in this context." };
|
||||
if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` };
|
||||
if (args.status === "deleted") {
|
||||
store.update(args.taskId, args);
|
||||
return { deleted: args.taskId };
|
||||
}
|
||||
const task = store.update(args.taskId, args);
|
||||
return task ? { task: serializeTask(task) } : { deleted: args.taskId };
|
||||
},
|
||||
};
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
import type { TaskStore } from "./task.js";
|
||||
|
||||
export interface SubAgentTask {
|
||||
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
|
||||
@@ -7,6 +8,20 @@ export interface SubAgentTask {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/** Result of one sub-agent in a parallel batch: either its final text, or the error that
|
||||
* terminated it (timeout, MaxIterationsError, a thrown tool error, etc.). Kept separate from a
|
||||
* plain string so the parent model can see at a glance which sub-tasks succeeded and which it
|
||||
* needs to retry or work around — one failed sub-task shouldn't discard the (potentially
|
||||
* expensive) results of its siblings. */
|
||||
export interface SubAgentResult {
|
||||
description: string;
|
||||
/** The sub-agent's final text answer, or undefined if it failed before producing one. */
|
||||
result?: string;
|
||||
/** Present when the sub-agent failed. A timeout, MaxIterationsError, or any other thrown error
|
||||
* surfaces here rather than rejecting the whole batch. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SubAgentOverrides {
|
||||
/** Replaces locode's generic sub-agent system prompt entirely — used by plugin-defined agents
|
||||
* (agents/*.md) that ship their own identity/instructions instead of the generic "delegate a
|
||||
@@ -24,11 +39,16 @@ export interface TodoItem {
|
||||
|
||||
export interface ToolContext {
|
||||
cwd: string;
|
||||
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
|
||||
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent`
|
||||
* tool). Runs a SINGLE sub-agent and returns its final text; a parallel batch is the `agent`
|
||||
* tool's own responsibility (it calls this once per task). */
|
||||
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<string>;
|
||||
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
|
||||
* future tool context is built without one — every session-backed context provides it. */
|
||||
setTodos?: (todos: TodoItem[]) => void;
|
||||
/** The session's structured task store (used by the task_create/list/get/update tools). Absent
|
||||
* only if a tool context is built without one — every session-backed context provides it. */
|
||||
taskStore?: TaskStore;
|
||||
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
|
||||
* polls `requested` and, once true, detaches into the background job registry instead of
|
||||
* awaiting completion. Absent for tools that don't support backgrounding. */
|
||||
|
||||
@@ -19,7 +19,8 @@ function isBinaryContentType(contentType: string): boolean {
|
||||
export const webFetchTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "web_fetch",
|
||||
description:
|
||||
"Fetch a URL and return readable text (HTML tags/scripts/styles stripped). Use for specific pages found via web_search.",
|
||||
"Fetch a URL and return readable text (HTML tags/scripts/styles stripped). Use for specific pages found via web_search — " +
|
||||
"e.g. to read a doc page or blog post in full when the search snippet was not enough.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ url }) => {
|
||||
|
||||
@@ -45,7 +45,8 @@ function parseResults(html: string, limit: number): SearchResult[] {
|
||||
export const webSearchTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "web_search",
|
||||
description:
|
||||
"Search the web via DuckDuckGo. Returns title, url, snippet. Use for info not in the local codebase.",
|
||||
"Search the web via DuckDuckGo. Returns title, url, snippet. Use for info not in the local codebase — " +
|
||||
"e.g. an unfamiliar API, library docs, or an error message. Follow up with web_fetch on a specific result for full page text.",
|
||||
schema,
|
||||
mutating: false,
|
||||
handler: async ({ query, max_results }) => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { writeFileTool } from "./writeFile.js";
|
||||
import type { ToolContext } from "./types.js";
|
||||
|
||||
describe("writeFile tool — path containment", () => {
|
||||
let cwd: string;
|
||||
let ctx: ToolContext;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-writefile-"));
|
||||
ctx = { cwd };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes a file inside the working directory", async () => {
|
||||
const result = (await writeFileTool.handler({ path: "note.txt", content: "hi" }, ctx)) as { path: string };
|
||||
expect(readFileSync(result.path, "utf-8")).toBe("hi");
|
||||
});
|
||||
|
||||
it("refuses to write outside the working directory via ../ traversal", async () => {
|
||||
await expect(writeFileTool.handler({ path: "../escape.txt", content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("refuses to write to an absolute path outside the working directory", async () => {
|
||||
const outside = path.join(os.tmpdir(), "locode-outside-target.txt");
|
||||
await expect(writeFileTool.handler({ path: outside, content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("preview reports the block instead of showing a diff", async () => {
|
||||
const preview = await writeFileTool.preview!({ path: "../escape.txt", content: "oops" }, ctx);
|
||||
expect(preview).toMatch(/outside the working directory/);
|
||||
});
|
||||
|
||||
it("still applies even when the escaping subdirectory already exists", async () => {
|
||||
// Sanity check that the guard runs before mkdir/write, not after.
|
||||
mkdirSync(path.join(cwd, "sub"), { recursive: true });
|
||||
await expect(writeFileTool.handler({ path: "sub/../../escape.txt", content: "oops" }, ctx)).rejects.toThrow(/outside the working directory/);
|
||||
});
|
||||
});
|
||||
+23
-8
@@ -1,7 +1,9 @@
|
||||
import { createPatch } from "diff";
|
||||
import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile as fsReadFile, rename, unlink, writeFile as fsWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { resolveWithinCwd } from "./pathGuard.js";
|
||||
import type { ToolDef } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -12,18 +14,24 @@ const schema = z.object({
|
||||
async function readExisting(resolved: string): Promise<string | null> {
|
||||
try {
|
||||
return await fsReadFile(resolved, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
name: "write_file",
|
||||
description: "Create or overwrite a file with the given content.",
|
||||
description: "Create or overwrite a file with the given content. Use for new files or full rewrites. For small changes to an existing file, prefer edit_file instead.",
|
||||
schema,
|
||||
mutating: true,
|
||||
preview: async ({ path: filePath, content }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
} catch (err) {
|
||||
return (err as Error).message;
|
||||
}
|
||||
const existing = await readExisting(resolved);
|
||||
if (existing === null) {
|
||||
return `Create new file ${resolved} (${content.length} chars)`;
|
||||
@@ -31,9 +39,16 @@ export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
|
||||
return createPatch(resolved, existing, content, "", "");
|
||||
},
|
||||
handler: async ({ path: filePath, content }, ctx) => {
|
||||
const resolved = path.resolve(ctx.cwd, filePath);
|
||||
const resolved = resolveWithinCwd(ctx.cwd, filePath);
|
||||
await mkdir(path.dirname(resolved), { recursive: true });
|
||||
await fsWriteFile(resolved, content, "utf-8");
|
||||
const tmpPath = resolved + ".tmp-" + randomUUID();
|
||||
try {
|
||||
await fsWriteFile(tmpPath, content, "utf-8");
|
||||
await rename(tmpPath, resolved);
|
||||
} catch (err) {
|
||||
try { await unlink(tmpPath); } catch {}
|
||||
throw err;
|
||||
}
|
||||
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
|
||||
},
|
||||
};
|
||||
};
|
||||
+448
-323
@@ -1,5 +1,6 @@
|
||||
import { Box, Text, useApp, useBoxMetrics, useInput, useWindowSize, type DOMElement } from "ink";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Box, Static, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from "ink";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AgentError,
|
||||
compactSession,
|
||||
@@ -26,10 +27,10 @@ import { setCachedMode } from "../../backend/capabilityCache.js";
|
||||
import { resolveContextWindow } from "../../backend/contextWindow.js";
|
||||
import { resolveToolCallMode } from "../../backend/resolveMode.js";
|
||||
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
|
||||
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
|
||||
import { getMcpStatuses } from "../../mcp/manager.js";
|
||||
import { KNOWN_BACKENDS, type BackendName, isSmallLocalModel } from "../../config/defaults.js";
|
||||
import { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
|
||||
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
|
||||
import { defaultExportFilename, exportSession } from "../../persistence/exportSession.js";
|
||||
import { defaultExportFilename, exportSession, type ExportFormat } from "../../persistence/exportSession.js";
|
||||
import { loadMergedHooks } from "../../hooks/config.js";
|
||||
import { expandCommandTemplate } from "../../plugins/expandTemplate.js";
|
||||
import { getLoadedPlugins, getPluginCommandCollisions } from "../../plugins/registry.js";
|
||||
@@ -46,11 +47,15 @@ import {
|
||||
type SessionRecord,
|
||||
type SessionSummary,
|
||||
} from "../../persistence/sessionStore.js";
|
||||
import { buildReplayHistory } from "../../persistence/replayHistory.js";
|
||||
import { onBackgroundJobDone } from "../../tools/backgroundJobs.js";
|
||||
import { TOOLS } from "../../tools/index.js";
|
||||
import type { ToolDef } from "../../tools/types.js";
|
||||
import { buildToolSet, type ToolSet } from "../../tools/toolset.js";
|
||||
import { ChatInput } from "./ChatInput.js";
|
||||
import { makeConfirmFn } from "./confirmFn.js";
|
||||
import { ExportPrompt } from "./ExportPrompt.js";
|
||||
import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js";
|
||||
import { HistoryItemView } from "./HistoryItemView.js";
|
||||
import { ModelSelect } from "./ModelSelect.js";
|
||||
import { PermissionPrompt } from "./PermissionPrompt.js";
|
||||
@@ -63,6 +68,20 @@ import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
|
||||
// Cap for the input-history ring buffer used for ↑/↓ recall in the chat input.
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
// Fixed width (in columns) of the file panel (see FilePanel.tsx) when shown — a compromise between
|
||||
// filenames actually fitting and leaving enough room for the chat column on an 80-col terminal.
|
||||
const FILE_PANEL_WIDTH = 30;
|
||||
|
||||
// Maps a tool name to how the file panel's Activity tab should label a successful call that
|
||||
// touched a file — see the tool_result handling in submitTurn. Every one of these tools returns
|
||||
// `{ path: <resolved absolute path>, ... }` from its handler (see readFile.ts/writeFile.ts/
|
||||
// editFile.ts), which is what makes a single lookup here enough to build a TouchedFile entry.
|
||||
const FILE_TOUCH_STATUS: Partial<Record<string, TouchedFile["status"]>> = {
|
||||
read_file: "read",
|
||||
write_file: "written",
|
||||
edit_file: "edited",
|
||||
};
|
||||
|
||||
// Shared between /perm's explicit-cycle notice and Shift+Tab's cyclePermMode so the two paths to
|
||||
// the same action can't drift out of sync with each other.
|
||||
const PERM_MODE_LABELS: Record<PermissionMode, string> = {
|
||||
@@ -107,31 +126,42 @@ export function App({
|
||||
onSessionIdChange,
|
||||
}: AppProps) {
|
||||
const { exit } = useApp();
|
||||
const { stdin, isRawModeSupported, setRawMode } = useStdin();
|
||||
const { stdout } = useStdout();
|
||||
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models",
|
||||
);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [permission, setPermission] = useState<PendingPermission | null>(null);
|
||||
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string } | null>(null);
|
||||
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null);
|
||||
const [streamingText, setStreamingText] = useState<string | null>(null);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [permMode, setPermMode] = useState<PermissionMode>("default");
|
||||
const [modelList, setModelList] = useState<string[]>([]);
|
||||
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
||||
const [filePanelVisible, setFilePanelVisible] = useState(false);
|
||||
const [filePanelTab, setFilePanelTab] = useState<FilePanelTab>("files");
|
||||
// Whether the file panel currently owns keyboard input — see the Ctrl+F handler below and
|
||||
// ChatInput's isActive prop, which this disables while true so an arrow/Enter/Escape keystroke
|
||||
// doesn't simultaneously navigate the tree and edit/submit the chat input.
|
||||
const [filePanelFocused, setFilePanelFocused] = useState(false);
|
||||
// Keyed by relPath so repeated touches update the same entry (bumping count) instead of
|
||||
// duplicating it — see the tool_result handling in submitTurn below.
|
||||
const [touchedFiles, setTouchedFiles] = useState<Map<string, TouchedFile>>(new Map());
|
||||
// Most-recently-touched first — the Activity tab's whole point is "what's happened lately".
|
||||
const touchedFilesList = useMemo(() => Array.from(touchedFiles.values()).sort((a, b) => b.lastTouchedAt - a.lastTouchedAt), [touchedFiles]);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
|
||||
const baseURLRef = useRef(initialBaseURL);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
// Wraps whichever branch the bottom ternary renders (permission/export prompt, a picker, or the
|
||||
// normal StatusBar+ChatInput column) — measured (height only) so the history viewport above it
|
||||
// knows exactly how much vertical space is left (see historyHeight).
|
||||
const bottomSectionRef = useRef<DOMElement | null>(null);
|
||||
// Measures the history content's own natural (unclipped) height — Yoga still computes a child's
|
||||
// intrinsic size even when its parent has a fixed height + overflowY:hidden, so this reports the
|
||||
// *true* height regardless of clipping. Used to decide top-alignment vs bottom-alignment below.
|
||||
const historyContentRef = useRef<DOMElement | null>(null);
|
||||
// The AbortController for whichever top-level turn is currently in flight — null between turns.
|
||||
// Escape (see the global useInput handler below) aborts it, which cancels the in-flight backend
|
||||
// request, kills a running bash child (see gateAndRun/bashTool's ctx.signal), and dismisses/rejects
|
||||
// a pending permission prompt (via makeConfirmFn) — the same abort plumbing sub-agent timeouts
|
||||
// already used, just wired up to a top-level turn for the first time.
|
||||
const turnAbortRef = useRef<AbortController | null>(null);
|
||||
// Static history items can't be retroactively expanded once printed (Ink's <Static> is
|
||||
// append-only), so Ctrl+O doesn't edit the collapsed compact notice in place — it prints a new
|
||||
// item with the full text on demand. This just remembers the most recent one to print.
|
||||
@@ -143,29 +173,22 @@ export function App({
|
||||
|
||||
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
|
||||
const streamingAccumulatorRef = useRef("");
|
||||
const thinkingAccumulatorRef = useRef("");
|
||||
const lastStreamRenderRef = useRef(0);
|
||||
const streamRafRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Stable ref for the current phase so the global useInput handler can read it without being
|
||||
// re-registered on every phase change.
|
||||
const phaseRef = useRef<Phase>(phase);
|
||||
phaseRef.current = phase;
|
||||
const { height: bottomSectionHeight, hasMeasured: bottomSectionMeasured } = useBoxMetrics(bottomSectionRef);
|
||||
const { rows: terminalRows } = useWindowSize();
|
||||
// Estimated fallback for the one frame before bottomSectionRef's first real measurement lands
|
||||
// (StatusBar ~2 rows + a single-line bordered ChatInput ~3 rows) — avoids a brief overflow/flash
|
||||
// of the history viewport claiming the whole terminal height on first mount.
|
||||
const FALLBACK_BOTTOM_HEIGHT = 6;
|
||||
const historyHeight = Math.max(
|
||||
1,
|
||||
terminalRows - (bottomSectionMeasured ? bottomSectionHeight : FALLBACK_BOTTOM_HEIGHT),
|
||||
);
|
||||
const { height: historyContentHeight } = useBoxMetrics(historyContentRef);
|
||||
// Short conversations (or right after connecting, with just the welcome banner) should stay
|
||||
// top-aligned — flex-end would otherwise glue even a single item to the bottom of the viewport,
|
||||
// leaving a large empty gap above it. Only once real content actually exceeds the available
|
||||
// height does it make sense to bottom-align and clip the oldest (off-the-top) content, which is
|
||||
// what makes the view auto-scroll to the latest message once a conversation grows past one screen.
|
||||
const historyOverflows = historyContentHeight > historyHeight;
|
||||
const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
|
||||
// Columns actually left for the chat column once the file panel (see FilePanel.tsx) claims its
|
||||
// fixed width on the right — ChatInput can't derive this from its own measured width (see the
|
||||
// comment on its availableColumns prop), so it's computed once here and threaded down.
|
||||
const chatColumns = terminalColumns - (filePanelVisible ? FILE_PANEL_WIDTH : 0);
|
||||
// Width every printed history line is held within, so nothing the terminal would soft-wrap
|
||||
// reaches Ink's <Static> — a soft-wrapped line desyncs Ink's redraw accounting and leaves
|
||||
// stranded copies of the status bar / input box in scrollback (see renderMarkdown).
|
||||
const contentWidth = Math.max(20, chatColumns);
|
||||
|
||||
const flushStreamingText = useCallback(() => {
|
||||
const accumulated = streamingAccumulatorRef.current;
|
||||
@@ -191,12 +214,6 @@ export function App({
|
||||
getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null));
|
||||
}, [cwd]);
|
||||
|
||||
// Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit,
|
||||
// bash) can switch branches or change the dirty state mid-session.
|
||||
useEffect(() => {
|
||||
refreshGitInfo();
|
||||
}, [refreshGitInfo]);
|
||||
|
||||
// A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has
|
||||
// ended — this is how its completion still reaches the transcript.
|
||||
useEffect(() => {
|
||||
@@ -209,9 +226,46 @@ export function App({
|
||||
// Mounted for the whole App lifetime (unlike ChatInput's own useInput, which only exists while
|
||||
// ChatInput is rendered) so both shortcuts work even mid-turn, when ChatInput is unmounted.
|
||||
useInput((input, key) => {
|
||||
// Escape interrupts an in-flight turn (like Claude Code) — checked first and unconditionally on
|
||||
// phase/modal state so it always wins, including while a permission prompt is open (aborting
|
||||
// dismisses it too, via makeConfirmFn) or the file panel is focused. Gated on isThinking/
|
||||
// streamingText rather than the phase/permission/exportPrompt guard below, since those describe
|
||||
// UI modal state, not whether a turn is actually running.
|
||||
if (key.escape && (isThinking || streamingText !== null)) {
|
||||
turnAbortRef.current?.abort();
|
||||
return;
|
||||
}
|
||||
// Shift+Tab: cycle permission mode. Handled globally (not just inside ChatInput) so it still
|
||||
// works while a turn is in flight or a permission prompt is open — both unmount ChatInput (see
|
||||
// the bottom-section ternary below), which is exactly the gap Escape hit above. Requires an
|
||||
// active session (phase "input"); excluded only for exportPrompt, where it isn't a meaningful
|
||||
// action while naming a file.
|
||||
if (key.shift && key.tab && phaseRef.current === "input" && !exportPrompt) {
|
||||
cyclePermMode();
|
||||
return;
|
||||
}
|
||||
// Only react to global shortcuts during the actual chat phase; ignore them while a modal
|
||||
// (permission/export) or a non-input phase (model/session select, connecting) is open.
|
||||
if (phaseRef.current !== "input" || permission || exportPrompt) return;
|
||||
if (key.ctrl && input === "f") {
|
||||
// Three-state cycle: hidden -> open+focused -> open+unfocused (via Escape, not here) -> hidden.
|
||||
// Pressing Ctrl+F while open-but-unfocused (the Escape state) re-focuses it instead of hiding
|
||||
// it outright, so "peek without hiding" (Escape) and "close" (Ctrl+F again) stay distinct.
|
||||
if (!filePanelVisible) {
|
||||
setFilePanelVisible(true);
|
||||
setFilePanelFocused(true);
|
||||
} else if (filePanelFocused) {
|
||||
setFilePanelVisible(false);
|
||||
setFilePanelFocused(false);
|
||||
} else {
|
||||
setFilePanelFocused(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === "g") {
|
||||
setFilePanelTab((t) => (t === "files" ? "activity" : "files"));
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === "o") {
|
||||
const summary = lastCompactSummaryRef.current;
|
||||
push({
|
||||
@@ -251,12 +305,11 @@ export function App({
|
||||
});
|
||||
}, [push]);
|
||||
|
||||
// Ollama/LM Studio don't report a usable context length for every model (notably cloud-routed
|
||||
// models, e.g. "*:cloud" tags, whose metadata isn't the local GGUF info /api/show expects) — when
|
||||
// that happens contextWindow silently falls back to a small hardcoded default (see
|
||||
// backend/contextWindow.ts), which makes auto-compaction trigger far more often than the model's
|
||||
// real limit would require, burning extra summarization round-trips. Surface it so the user can
|
||||
// set the real value instead of silently eating that cost every session.
|
||||
// When neither backend reports a context length (model not loaded in LM Studio, /api/show
|
||||
// unreachable, a model type Ollama has no metadata for) contextWindow silently falls back to a
|
||||
// small hardcoded default (see backend/contextWindow.ts), which makes auto-compaction trigger far
|
||||
// more often than the model's real limit would require, burning extra summarization round-trips.
|
||||
// Surface it so the user can set the real value instead of silently eating that cost every session.
|
||||
const notifyIfContextWindowGuessed = useCallback(
|
||||
(model: string, contextWindow: { value: number; isEstimate: boolean }) => {
|
||||
if (!contextWindow.isEstimate) return;
|
||||
@@ -279,10 +332,7 @@ export function App({
|
||||
// Default to "native" mode immediately — no blocking probe on startup.
|
||||
// The probe runs lazily on the first turn if needed.
|
||||
const mode: ToolCallMode = toolModeOverride ?? "native";
|
||||
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
|
||||
new Promise<PermissionDecision>((resolve) => {
|
||||
setPermission({ ...opts, resolve });
|
||||
});
|
||||
const confirmFn = makeConfirmFn(setPermission);
|
||||
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
|
||||
extraToolsPromise,
|
||||
resolveContextWindow(baseURLRef.current, model),
|
||||
@@ -300,6 +350,7 @@ export function App({
|
||||
resolveMaxIterations(),
|
||||
resolveAutoCompactThreshold(),
|
||||
projectInstructions,
|
||||
isSmallLocalModel(baseURLRef.current, model),
|
||||
);
|
||||
onSessionIdChange?.(sessionRef.current.id);
|
||||
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
|
||||
@@ -331,10 +382,7 @@ export function App({
|
||||
try {
|
||||
baseURLRef.current = record.baseURL;
|
||||
const client = makeClient({ baseURL: record.baseURL, model: record.model });
|
||||
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
|
||||
new Promise<PermissionDecision>((resolve) => {
|
||||
setPermission({ ...opts, resolve });
|
||||
});
|
||||
const confirmFn = makeConfirmFn(setPermission);
|
||||
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
|
||||
extraToolsPromise,
|
||||
resolveContextWindow(record.baseURL, record.model),
|
||||
@@ -351,6 +399,7 @@ export function App({
|
||||
resolveMaxIterations(),
|
||||
resolveAutoCompactThreshold(),
|
||||
projectInstructions,
|
||||
isSmallLocalModel(record.baseURL, record.model),
|
||||
);
|
||||
onSessionIdChange?.(sessionRef.current.id);
|
||||
push({
|
||||
@@ -365,17 +414,9 @@ export function App({
|
||||
push({ kind: "notice", text: `Hook warning: ${warning}` });
|
||||
}
|
||||
|
||||
// Replay the saved user/assistant turns so the transcript isn't blank — tool
|
||||
// call/result lines aren't replayed since we don't persist their display labels.
|
||||
const replayed: HistoryItem[] = [];
|
||||
for (const m of record.messages) {
|
||||
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
|
||||
replayed.push({ id: nextId(), kind: "user", text: m.content });
|
||||
} else if (m.role === "assistant" && typeof m.content === "string" && m.content) {
|
||||
replayed.push({ id: nextId(), kind: "assistant", text: m.content });
|
||||
}
|
||||
}
|
||||
setStaticItems((prev) => [...prev, ...replayed]);
|
||||
// Replay the saved turns (including tool_call/tool_result lines) so the transcript
|
||||
// looks the same as it did live, not just the user/assistant text.
|
||||
setStaticItems((prev) => [...prev, ...buildReplayHistory(record.messages)]);
|
||||
|
||||
setPhase("input");
|
||||
} catch (err) {
|
||||
@@ -459,6 +500,10 @@ export function App({
|
||||
resolveToolCallMode(session.client, baseURLRef.current, name),
|
||||
resolveContextWindow(baseURLRef.current, name),
|
||||
]);
|
||||
// Recompute before setMode's rebuild (see setMode) so switching e.g. a small local model ->
|
||||
// glm-5.2:cloud (or the reverse) mid-session gets the right system-prompt branch immediately,
|
||||
// not just on the next full session restart.
|
||||
session.isLocal = isSmallLocalModel(baseURLRef.current, name);
|
||||
setMode(session, newMode);
|
||||
session.contextWindow = newContextWindow.value;
|
||||
session.contextWindowIsEstimate = newContextWindow.isEstimate;
|
||||
@@ -489,6 +534,9 @@ export function App({
|
||||
resolveToolCallMode(session.client, baseURLRef.current, session.model),
|
||||
resolveContextWindow(baseURLRef.current, session.model),
|
||||
]);
|
||||
// See the same recompute in switchModel — a backend switch changes isSmallLocalModel()'s
|
||||
// input just as much as a model switch changes isCloudRoutedModelName()'s.
|
||||
session.isLocal = isSmallLocalModel(baseURLRef.current, session.model);
|
||||
setMode(session, newMode);
|
||||
session.contextWindow = newContextWindow.value;
|
||||
session.contextWindowIsEstimate = newContextWindow.isEstimate;
|
||||
@@ -510,8 +558,10 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTurn(session: Session, input: string | ChatCompletionUserContent) {
|
||||
async function submitTurn(session: Session, input: string | ChatCompletionUserContent, toolset?: ToolSet) {
|
||||
const rollbackLength = session.messages.length;
|
||||
const ac = new AbortController();
|
||||
turnAbortRef.current = ac;
|
||||
setIsThinking(true);
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
@@ -565,12 +615,22 @@ export function App({
|
||||
} else if (event.type === "tool_result") {
|
||||
setRunningToolIsBash(false);
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
|
||||
const touchStatus = event.name && !event.isError ? FILE_TOUCH_STATUS[event.name] : undefined;
|
||||
const resultPath = touchStatus ? (event.result as { path?: unknown } | undefined)?.path : undefined;
|
||||
if (touchStatus && typeof resultPath === "string") {
|
||||
const relPath = path.relative(cwd, resultPath) || resultPath;
|
||||
setTouchedFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(relPath, { relPath, status: touchStatus, count: (next.get(relPath)?.count ?? 0) + 1, lastTouchedAt: Date.now() });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "hook_notice" || event.type === "notice") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
|
||||
} else if (event.type === "todos_update") {
|
||||
setStaticItems((prev) => [...prev, { id: nextId(), kind: "todos", todos: event.todos } as HistoryItem]);
|
||||
}
|
||||
});
|
||||
}, toolset, ac.signal);
|
||||
// text_done already added the assistant message to staticItems
|
||||
// No fallback needed — the streaming loop always emits text_done
|
||||
void text;
|
||||
@@ -608,7 +668,14 @@ export function App({
|
||||
// retry the edit on the next turn (which then fails with "old_string not found", etc.).
|
||||
const commitLength = session.mutationCommitLength;
|
||||
session.messages.length = commitLength ?? rollbackLength;
|
||||
if (err instanceof MaxIterationsError) {
|
||||
if (ac.signal.aborted) {
|
||||
// The user pressed Escape (see the global useInput handler) — not a failure, so no
|
||||
// "Request failed" framing. Whatever error actually surfaced (a raw fetch AbortError, or
|
||||
// makeConfirmFn's "prompt cancelled" rejection if Escape landed while a permission prompt
|
||||
// was open) is irrelevant here; `ac` is ours, so its own aborted flag is the one source of
|
||||
// truth for "this was the user stopping it" regardless of which code path threw.
|
||||
push({ kind: "notice", text: "Interrupted.", isError: false });
|
||||
} else if (err instanceof MaxIterationsError) {
|
||||
// Not a failure — the model just ran out of per-turn budget. No "Request failed" framing,
|
||||
// no isError styling, since nothing actually broke and the work done so far is intact.
|
||||
push({ kind: "notice", text: err.message, isError: false });
|
||||
@@ -617,6 +684,7 @@ export function App({
|
||||
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
|
||||
}
|
||||
} finally {
|
||||
if (turnAbortRef.current === ac) turnAbortRef.current = null;
|
||||
setIsThinking(false);
|
||||
setStreamingText(null);
|
||||
streamingAccumulatorRef.current = "";
|
||||
@@ -628,6 +696,11 @@ export function App({
|
||||
}
|
||||
|
||||
async function handleSubmit(raw: string) {
|
||||
// A turn is already in flight (streaming output or waiting for the first chunk). Keep the
|
||||
// input box mounted so the user can type ahead, but ignore Enter so a second turn can't
|
||||
// overlap onto the same session. The in-flight turn is the source of truth; the typed text
|
||||
// stays in the box for when the turn finishes.
|
||||
if (isThinking || streamingText !== null) return;
|
||||
setInputValue("");
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -646,187 +719,222 @@ export function App({
|
||||
|
||||
push({ kind: "user", text: trimmed });
|
||||
|
||||
if (trimmed === "/exit" || trimmed === "/quit") {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/help") {
|
||||
push({ kind: "help" });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/clear") {
|
||||
resetSession(session);
|
||||
push({ kind: "notice", text: "Conversation history cleared." });
|
||||
persistCurrentSession();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/status") {
|
||||
push({
|
||||
kind: "status",
|
||||
model: session.model,
|
||||
baseURL: baseURLRef.current,
|
||||
mode: session.mode,
|
||||
cwd,
|
||||
sessionId: session.id,
|
||||
contextTokens: session.lastContextTokens,
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/dashboard" || trimmed === "/stats") {
|
||||
push({
|
||||
kind: "dashboard",
|
||||
model: session.model,
|
||||
baseURL: baseURLRef.current,
|
||||
sessionId: session.id,
|
||||
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
|
||||
turns: session.stats.turns,
|
||||
apiCalls: session.stats.apiCalls,
|
||||
toolCalls: session.stats.toolCalls,
|
||||
inputTokens: session.stats.inputTokens,
|
||||
outputTokens: session.stats.outputTokens,
|
||||
modelTimeMs: session.stats.modelTimeMs,
|
||||
contextTokens: session.lastContextTokens,
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/compact") {
|
||||
setIsThinking(true);
|
||||
try {
|
||||
const summary = await compactSession(session);
|
||||
lastCompactSummaryRef.current = summary;
|
||||
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
|
||||
persistCurrentSession();
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/export")) {
|
||||
const arg = trimmed.slice("/export".length).trim();
|
||||
setExportPrompt({ defaultName: arg || defaultExportFilename() });
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/import")) {
|
||||
const rest = trimmed.slice("/import".length).trim();
|
||||
if (!rest) {
|
||||
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
|
||||
// Built-in slash commands, checked in order before plugin commands / skills / @mentions get a
|
||||
// look. `test` gets the trimmed input; `run` gets it too and slices its own argument (a prefix
|
||||
// match like `/model` deliberately stays loose — `startsWith` — to match historical behavior).
|
||||
// Order matters where prefixes overlap: `/permissions` sits before `/perm`, `/model` before
|
||||
// `/mode`.
|
||||
const builtinCommands: { test: (t: string) => boolean; run: (t: string) => void | Promise<void> }[] = [
|
||||
{ test: (t) => t === "/exit" || t === "/quit", run: () => exit() },
|
||||
{ test: (t) => t === "/help", run: () => push({ kind: "help" }) },
|
||||
{
|
||||
test: (t) => t === "/clear",
|
||||
run: () => {
|
||||
resetSession(session);
|
||||
push({ kind: "notice", text: "Conversation history cleared." });
|
||||
persistCurrentSession();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/status",
|
||||
run: () =>
|
||||
push({
|
||||
kind: "status",
|
||||
model: session.model,
|
||||
baseURL: baseURLRef.current,
|
||||
mode: session.mode,
|
||||
cwd,
|
||||
sessionId: session.id,
|
||||
contextTokens: session.lastContextTokens,
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/dashboard" || t === "/stats",
|
||||
run: () =>
|
||||
push({
|
||||
kind: "dashboard",
|
||||
model: session.model,
|
||||
baseURL: baseURLRef.current,
|
||||
sessionId: session.id,
|
||||
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
|
||||
turns: session.stats.turns,
|
||||
apiCalls: session.stats.apiCalls,
|
||||
toolCalls: session.stats.toolCalls,
|
||||
inputTokens: session.stats.inputTokens,
|
||||
outputTokens: session.stats.outputTokens,
|
||||
modelTimeMs: session.stats.modelTimeMs,
|
||||
contextTokens: session.lastContextTokens,
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/compact",
|
||||
run: async () => {
|
||||
setIsThinking(true);
|
||||
try {
|
||||
const summary = await compactSession(session);
|
||||
lastCompactSummaryRef.current = summary;
|
||||
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
|
||||
persistCurrentSession();
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/export"),
|
||||
run: (t) => {
|
||||
const rest = t.slice("/export".length).trim();
|
||||
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
|
||||
let fmt: ExportFormat = "markdown";
|
||||
let arg = rest;
|
||||
if (rest === "json" || rest.startsWith("json ")) {
|
||||
fmt = "json";
|
||||
arg = rest === "json" ? "" : rest.slice("json".length).trim();
|
||||
}
|
||||
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/import"),
|
||||
run: async (t) => {
|
||||
const rest = t.slice("/import".length).trim();
|
||||
if (!rest) {
|
||||
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
|
||||
return;
|
||||
}
|
||||
const spaceIdx = rest.indexOf(" ");
|
||||
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
|
||||
try {
|
||||
const content = await buildImportContent(cwd, filePathArg, caption);
|
||||
await submitTurn(session, content);
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ test: (t) => t === "/tools", run: () => push({ kind: "tools", tools: session.toolset.tools }) },
|
||||
{ test: (t) => t === "/permissions", run: () => push({ kind: "permissions", allowed: session.permissions.listAllowed() }) },
|
||||
{ test: (t) => t === "/sessions", run: () => push({ kind: "sessions", sessions: listSessions() }) },
|
||||
{
|
||||
test: (t) => t === "/mcp" || t === "/mcp reconnect",
|
||||
run: async (t) => {
|
||||
if (t === "/mcp reconnect" && sessionRef.current) {
|
||||
try {
|
||||
const newMcpTools = await reconnectMcpServers(cwd);
|
||||
// Rebuild the session's toolset: keep non-MCP tools (built-ins + plugin tools already in
|
||||
// the toolset), drop the old MCP tools (namespaced `mcp__...`), and add the fresh ones.
|
||||
const kept = sessionRef.current.toolset.tools.filter((tool) => !tool.name.startsWith("mcp__"));
|
||||
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
|
||||
} catch {
|
||||
// fall through to just re-rendering current statuses
|
||||
}
|
||||
}
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/plugins",
|
||||
run: () => push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() }),
|
||||
},
|
||||
{ test: (t) => t === "/hooks", run: () => push({ kind: "hooks", config: loadMergedHooks(cwd) }) },
|
||||
{
|
||||
test: (t) => t === "/skills",
|
||||
run: () => {
|
||||
const skills = getLoadedPlugins().flatMap((p) => p.skills);
|
||||
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/model"),
|
||||
run: async (t) => {
|
||||
const name = t.slice("/model".length).trim();
|
||||
if (!name) push({ kind: "notice", text: `Current model: ${session.model}` });
|
||||
else await switchModel(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/backend"),
|
||||
run: async (t) => {
|
||||
const name = t.slice("/backend".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
|
||||
} else if (name !== "ollama" && name !== "lmstudio") {
|
||||
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
|
||||
} else {
|
||||
await switchBackend(name);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/perm"),
|
||||
run: (t) => {
|
||||
const name = t.slice("/perm".length).trim();
|
||||
const validModes: Record<string, PermissionMode> = {
|
||||
default: "default",
|
||||
plan: "plan",
|
||||
"auto-edit": "auto-edit",
|
||||
"auto-accept": "auto-accept",
|
||||
};
|
||||
if (!name) {
|
||||
// Cycle
|
||||
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
|
||||
const currentIdx = modes.indexOf(permMode);
|
||||
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||
setPermMode(nextMode);
|
||||
session.permissions.setMode(nextMode);
|
||||
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
|
||||
} else if (validModes[name]) {
|
||||
const newMode = validModes[name];
|
||||
setPermMode(newMode);
|
||||
session.permissions.setMode(newMode);
|
||||
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
|
||||
} else {
|
||||
push({
|
||||
kind: "notice",
|
||||
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/mode"),
|
||||
run: (t) => {
|
||||
const name = t.slice("/mode".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
|
||||
} else if (name !== "native" && name !== "fallback") {
|
||||
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
|
||||
} else {
|
||||
const previousMode = session.mode;
|
||||
setMode(session, name);
|
||||
setCachedMode(baseURLRef.current, session.model, name);
|
||||
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
||||
persistCurrentSession();
|
||||
runHooksForEvent(
|
||||
"ConfigChange",
|
||||
{ sessionId: session.id, cwd },
|
||||
{ key: "toolMode", previous: previousMode, value: name },
|
||||
).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const command of builtinCommands) {
|
||||
if (command.test(trimmed)) {
|
||||
await command.run(trimmed);
|
||||
return;
|
||||
}
|
||||
const spaceIdx = rest.indexOf(" ");
|
||||
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
|
||||
try {
|
||||
const content = await buildImportContent(cwd, filePathArg, caption);
|
||||
await submitTurn(session, content);
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/tools") {
|
||||
push({ kind: "tools", tools: session.toolset.tools });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/permissions") {
|
||||
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/sessions") {
|
||||
push({ kind: "sessions", sessions: listSessions() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/mcp") {
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/plugins") {
|
||||
push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/hooks") {
|
||||
push({ kind: "hooks", config: loadMergedHooks(cwd) });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/skills") {
|
||||
const skills = getLoadedPlugins().flatMap((p) => p.skills);
|
||||
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/model")) {
|
||||
const name = trimmed.slice("/model".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current model: ${session.model}` });
|
||||
} else {
|
||||
await switchModel(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/backend")) {
|
||||
const name = trimmed.slice("/backend".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
|
||||
} else if (name !== "ollama" && name !== "lmstudio") {
|
||||
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
|
||||
} else {
|
||||
await switchBackend(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/perm")) {
|
||||
const name = trimmed.slice("/perm".length).trim();
|
||||
const validModes: Record<string, PermissionMode> = {
|
||||
default: "default",
|
||||
plan: "plan",
|
||||
"auto-edit": "auto-edit",
|
||||
"auto-accept": "auto-accept",
|
||||
};
|
||||
if (!name) {
|
||||
// Cycle
|
||||
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
|
||||
const currentIdx = modes.indexOf(permMode);
|
||||
const nextMode = modes[(currentIdx + 1) % modes.length]!;
|
||||
setPermMode(nextMode);
|
||||
session.permissions.setMode(nextMode);
|
||||
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
|
||||
} else if (validModes[name]) {
|
||||
const newMode = validModes[name];
|
||||
setPermMode(newMode);
|
||||
session.permissions.setMode(newMode);
|
||||
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
|
||||
} else {
|
||||
push({
|
||||
kind: "notice",
|
||||
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/mode")) {
|
||||
const name = trimmed.slice("/mode".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
|
||||
} else if (name !== "native" && name !== "fallback") {
|
||||
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
|
||||
} else {
|
||||
const previousMode = session.mode;
|
||||
setMode(session, name);
|
||||
setCachedMode(baseURLRef.current, session.model, name);
|
||||
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
||||
persistCurrentSession();
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
|
||||
@@ -860,7 +968,13 @@ export function App({
|
||||
const pluginCommand = plugins.flatMap((p) => p.commands).find((c) => c.name === cmdName);
|
||||
if (pluginCommand) {
|
||||
const expanded = expandCommandTemplate(pluginCommand.template, argsText);
|
||||
await submitTurn(session, extraContext ? `${expanded}\n\n${extraContext}` : expanded);
|
||||
// A command's `allowed-tools` frontmatter restricts only this one turn — build a
|
||||
// narrowed ToolSet from the session's full one rather than mutating session.toolset,
|
||||
// the same pattern runSubAgentTurn (agent/loop.ts) uses for a plugin agent's `tools:`.
|
||||
const restrictedToolset = pluginCommand.allowedTools
|
||||
? buildToolSet(session.toolset.tools.filter((t) => pluginCommand.allowedTools!.includes(t.name)))
|
||||
: undefined;
|
||||
await submitTurn(session, extraContext ? `${expanded}\n\n${extraContext}` : expanded, restrictedToolset);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -895,6 +1009,19 @@ export function App({
|
||||
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
|
||||
}
|
||||
|
||||
// handleSubmit is a plain function recreated every render (it's 300 lines closing over dozens of
|
||||
// things, including isThinking/streamingText — both of which change during every streaming frame,
|
||||
// so a naive useCallback with a correct dependency list would still get a new identity constantly
|
||||
// while streaming, exactly when we want it stable). Instead, the same ref-indirection this file
|
||||
// already uses for phaseRef: keep a ref pointing at the latest handleSubmit, and hand ChatInput a
|
||||
// permanently-stable wrapper that just calls through it — this is what actually makes ChatInput's
|
||||
// memo() below effective during streaming, without needing handleSubmit's own identity to be stable.
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
const stableHandleSubmit = useCallback((raw: string) => {
|
||||
void handleSubmitRef.current(raw);
|
||||
}, []);
|
||||
|
||||
function handlePermissionSelect(decision: PermissionDecision) {
|
||||
const pending = permission;
|
||||
setPermission(null);
|
||||
@@ -912,7 +1039,7 @@ export function App({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName);
|
||||
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName, exportPrompt?.format ?? "markdown");
|
||||
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
|
||||
@@ -936,94 +1063,92 @@ export function App({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" height={terminalRows}>
|
||||
{/* Fixed-height, bottom-pinned viewport: overflowY="hidden" clips whatever scrolls past the
|
||||
* top, and justifyContent="flex-end" keeps the *latest* content flush against the bottom
|
||||
* edge — together they auto-scroll to the newest message without any manual scroll-offset
|
||||
* math, the same way a normal chat view does. This replaced <Static> (permanent one-shot
|
||||
* scrollback printing) because Static's already-flushed rows never participate in Yoga
|
||||
* layout again, which is fundamentally incompatible with letting old items visually scroll
|
||||
* out of a *bounded* viewport as new ones arrive. Trade-off: every item re-renders on every
|
||||
* frame now (Static rendered each item exactly once, ever) — fine at the sizes a single
|
||||
* session reaches before auto-compaction, but worth knowing if a session gets huge. */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={historyHeight}
|
||||
overflowY="hidden"
|
||||
justifyContent={historyOverflows ? "flex-end" : "flex-start"}
|
||||
>
|
||||
<Box flexDirection="column" ref={historyContentRef}>
|
||||
{staticItems.map((item) => (
|
||||
<HistoryItemView key={item.id} item={item} />
|
||||
))}
|
||||
<>
|
||||
{/* Finished history prints once, directly to the terminal's real scrollback — never
|
||||
* re-rendered, never height-clipped — which is what makes the terminal's own native mouse
|
||||
* wheel scroll and click-drag text selection/copy work with zero app-side mouse tracking or
|
||||
* virtual-scroll code (see index.tsx for why locode also stays off the alternate screen). */}
|
||||
<Static items={staticItems}>
|
||||
{(item) => (
|
||||
<Box key={item.id} width={contentWidth} flexShrink={0}>
|
||||
<HistoryItemView item={item} width={contentWidth} />
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
<Box flexDirection="row" width="100%" alignItems="flex-end">
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
|
||||
{streamingText !== null && (
|
||||
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
|
||||
<Box width={contentWidth} flexShrink={0}>
|
||||
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} width={contentWidth} />
|
||||
</Box>
|
||||
)}
|
||||
{isThinking && streamingText === null && !permission && !exportPrompt && (
|
||||
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" ref={bottomSectionRef}>
|
||||
{permission ? (
|
||||
<PermissionPrompt
|
||||
toolName={permission.toolName}
|
||||
args={permission.args}
|
||||
preview={permission.preview}
|
||||
onSelect={handlePermissionSelect}
|
||||
/>
|
||||
) : exportPrompt ? (
|
||||
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
|
||||
) : phase === "starting" ? (
|
||||
<ThinkingIndicator label="starting..." />
|
||||
) : phase === "connecting" ? (
|
||||
<ThinkingIndicator label="connecting..." />
|
||||
) : phase === "loading-models" ? (
|
||||
<ThinkingIndicator label="loading models..." />
|
||||
) : phase === "session-select" ? (
|
||||
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
|
||||
) : phase === "model-select" ? (
|
||||
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
||||
) : (
|
||||
<>
|
||||
{sessionRef.current && phaseRef.current === "input" && (
|
||||
<StatusBar
|
||||
model={sessionRef.current.model}
|
||||
mode={sessionRef.current.mode}
|
||||
permMode={permMode}
|
||||
cwd={cwd}
|
||||
contextTokens={sessionRef.current.lastContextTokens}
|
||||
contextWindow={sessionRef.current.contextWindow}
|
||||
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
|
||||
sessionId={sessionRef.current.id}
|
||||
createdAt={sessionRef.current.createdAt}
|
||||
inputTokens={sessionRef.current.stats.inputTokens}
|
||||
outputTokens={sessionRef.current.stats.outputTokens}
|
||||
gitInfo={gitInfo}
|
||||
/>
|
||||
)}
|
||||
{isThinking || streamingText !== null ? (
|
||||
// Swapped in for ChatInput while a turn is in flight. Closes a real (if rare) race:
|
||||
// handleSubmit had no guard against firing while a turn was already running, so
|
||||
// typing and hitting Enter mid-stream could submit a second overlapping turn onto
|
||||
// the same session.
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
|
||||
<Text dimColor>Waiting for response…</Text>
|
||||
</Box>
|
||||
) : (
|
||||
{permission ? (
|
||||
<PermissionPrompt
|
||||
toolName={permission.toolName}
|
||||
args={permission.args}
|
||||
preview={permission.preview}
|
||||
onSelect={handlePermissionSelect}
|
||||
/>
|
||||
) : exportPrompt ? (
|
||||
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
|
||||
) : phase === "starting" ? (
|
||||
<ThinkingIndicator label="starting..." />
|
||||
) : phase === "connecting" ? (
|
||||
<ThinkingIndicator label="connecting..." />
|
||||
) : phase === "loading-models" ? (
|
||||
<ThinkingIndicator label="loading models..." />
|
||||
) : phase === "session-select" ? (
|
||||
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
|
||||
) : phase === "model-select" ? (
|
||||
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
|
||||
) : (
|
||||
<>
|
||||
{sessionRef.current && phaseRef.current === "input" && (
|
||||
<StatusBar
|
||||
model={sessionRef.current.model}
|
||||
mode={sessionRef.current.mode}
|
||||
permMode={permMode}
|
||||
cwd={cwd}
|
||||
contextTokens={sessionRef.current.lastContextTokens}
|
||||
contextWindow={sessionRef.current.contextWindow}
|
||||
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
|
||||
sessionId={sessionRef.current.id}
|
||||
createdAt={sessionRef.current.createdAt}
|
||||
inputTokens={sessionRef.current.stats.inputTokens}
|
||||
outputTokens={sessionRef.current.stats.outputTokens}
|
||||
modelTimeMs={sessionRef.current.stats.modelTimeMs}
|
||||
gitInfo={gitInfo}
|
||||
/>
|
||||
)}
|
||||
{(isThinking || streamingText !== null) && (
|
||||
<Text dimColor> Waiting for response… (esc to interrupt) — type ahead, Enter sends when done.</Text>
|
||||
)}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onCyclePermMode={cyclePermMode}
|
||||
onSubmit={stableHandleSubmit}
|
||||
cwd={cwd}
|
||||
history={history}
|
||||
availableColumns={chatColumns}
|
||||
isActive={!filePanelFocused}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<FilePanel
|
||||
visible={filePanelVisible}
|
||||
activeTab={filePanelTab}
|
||||
cwd={cwd}
|
||||
touchedFiles={touchedFilesList}
|
||||
width={FILE_PANEL_WIDTH}
|
||||
height={terminalRows}
|
||||
focused={filePanelFocused}
|
||||
onExitFocus={() => setFilePanelFocused(false)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+153
-43
@@ -1,18 +1,29 @@
|
||||
import { Box, Text, useBoxMetrics, useCursor, useInput, useWindowSize, type DOMElement } from "ink";
|
||||
import fg from "fast-glob";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { getAbsolutePosition } from "./absolutePosition.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import { getActiveMention } from "../../utils/mentions.js";
|
||||
|
||||
// Bracketed paste markers emitted by terminals when the user pastes text.
|
||||
const BP_START = "\x1b[200~";
|
||||
const BP_END = "\x1b[201~";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onCyclePermMode?: () => void;
|
||||
cwd: string;
|
||||
history?: string[];
|
||||
/** Actual terminal columns available to this box's content — normally the full terminal width,
|
||||
* but narrower when the file panel (App.tsx) is showing alongside it. Defaults to the terminal's
|
||||
* own column count so callers that don't have a side panel can omit it. */
|
||||
availableColumns?: number;
|
||||
/** False while the file panel has keyboard focus (App.tsx) — disables this component's own
|
||||
* useInput so the same keystroke (arrows, Enter, Escape) doesn't also edit/submit the chat
|
||||
* input while it's being used to navigate the file tree. Defaults to true. */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const MAX_MATCHES = 50;
|
||||
@@ -22,7 +33,7 @@ const PROMPT_WIDTH = 2;
|
||||
// Border (1 col each side) + paddingX={1} (1 col each side) around the bordered box's content.
|
||||
const BOX_CHROME_WIDTH = 4;
|
||||
|
||||
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, history = [] }: Props) {
|
||||
export const ChatInput = memo(function ChatInput({ value, onChange, onSubmit, cwd, history = [], availableColumns, isActive = true }: Props) {
|
||||
const [allFiles, setAllFiles] = useState<string[] | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
@@ -43,33 +54,60 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
// time (through however many wrapper Boxes sit above this one) proved fragile in practice.
|
||||
const { hasMeasured } = useBoxMetrics(boxRef);
|
||||
const { setCursorPosition } = useCursor();
|
||||
// Terminal columns, not this box's own measured width: the input box is always width="100%"
|
||||
// with no horizontal siblings, so its content width is deterministically `columns -
|
||||
// BOX_CHROME_WIDTH`. Using the measured width instead briefly produced a near-zero value before
|
||||
// the box's first real layout pass landed (hasMeasured only means the ref is attached, not that
|
||||
// Yoga has computed a real width yet) — with content width floored at 1, every single typed
|
||||
// character was computed as needing its own wrapped row, so the reported cursor row grew by one
|
||||
// per keystroke, visibly "falling" down the screen as you typed.
|
||||
// Terminal columns, not this box's own measured width: the input box is always width="100%" of
|
||||
// its column (deterministic), so its content width is `availableColumns - BOX_CHROME_WIDTH`.
|
||||
// Using the measured width instead briefly produced a near-zero value before the box's first real
|
||||
// layout pass landed (hasMeasured only means the ref is attached, not that Yoga has computed a
|
||||
// real width yet) — with content width floored at 1, every single typed character was computed as
|
||||
// needing its own wrapped row, so the reported cursor row grew by one per keystroke, visibly
|
||||
// "falling" down the screen as you typed. `availableColumns` defaults to the raw terminal width
|
||||
// for callers with no horizontal siblings; App.tsx passes the narrower figure when the file panel
|
||||
// is showing alongside this box, since it isn't full terminal width in that case.
|
||||
const { columns: terminalColumns } = useWindowSize();
|
||||
const contentColumns = availableColumns ?? terminalColumns;
|
||||
|
||||
const mention = getActiveMention(value);
|
||||
|
||||
// Glob the project's files lazily — only once a "@" is actually typed — and cache the result
|
||||
// for the rest of the session rather than re-scanning on every keystroke.
|
||||
// for up to 5 minutes (re-glob after that to pick up newly created/deleted files).
|
||||
const [filesCachedAt, setFilesCachedAt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!mention || allFiles !== null) return;
|
||||
if (!mention) return;
|
||||
const now = Date.now();
|
||||
if (allFiles !== null && now - filesCachedAt < 300_000) return; // 5 min cache
|
||||
let cancelled = false;
|
||||
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: ["node_modules/**", ".git/**", "dist/**"] })
|
||||
.then((files) => {
|
||||
if (!cancelled) setAllFiles(files);
|
||||
if (!cancelled) {
|
||||
setAllFiles(files);
|
||||
setFilesCachedAt(Date.now());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAllFiles([]);
|
||||
if (!cancelled) {
|
||||
setAllFiles([]);
|
||||
setFilesCachedAt(Date.now());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mention !== null, allFiles, cwd]);
|
||||
}, [mention !== null, allFiles, cwd, filesCachedAt]);
|
||||
|
||||
// Fuzzy path matching: split the query and file into segments, matching each query
|
||||
// token against consecutive characters in any path segment (e.g. "ut" matches "utils/").
|
||||
function fuzzyMatch(query: string, filePath: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const f = filePath.toLowerCase();
|
||||
// Fast path: exact substring match
|
||||
if (f.includes(q)) return true;
|
||||
// Fuzzy: split query into characters and check if they appear in order across path segments
|
||||
let qi = 0;
|
||||
for (let fi = 0; fi < f.length && qi < q.length; fi++) {
|
||||
if (f[fi] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
// The full match set (capped at MAX_MATCHES for sanity) — separate from what's actually
|
||||
// rendered, since only a VISIBLE_SUGGESTIONS-tall window of it is shown at once (see `visible`).
|
||||
@@ -77,8 +115,14 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
const matches =
|
||||
mention && allFiles
|
||||
? allFiles
|
||||
.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.filter((f) => fuzzyMatch(query, f))
|
||||
.sort((a, b) => {
|
||||
// Exact match first, then by length
|
||||
const aExact = a.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
|
||||
const bExact = b.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
|
||||
if (aExact !== bExact) return aExact - bExact;
|
||||
return a.length - b.length;
|
||||
})
|
||||
.slice(0, MAX_MATCHES)
|
||||
: [];
|
||||
|
||||
@@ -103,19 +147,25 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
// is up to date before this same render's insertion effect runs.
|
||||
if (hasMeasured) {
|
||||
const origin = getAbsolutePosition(boxRef.current);
|
||||
const contentWidth = Math.max(1, terminalColumns - BOX_CHROME_WIDTH);
|
||||
const contentWidth = Math.max(1, contentColumns - BOX_CHROME_WIDTH);
|
||||
const totalWidth = PROMPT_WIDTH + stringWidth(value.slice(0, cursorOffset));
|
||||
const row = Math.floor(totalWidth / contentWidth);
|
||||
const col = totalWidth % contentWidth;
|
||||
// +2 rows / +2 cols for the box's own top border and left border+padding —
|
||||
// getAbsolutePosition gives the box's outer (border) edge, not where its content actually
|
||||
// starts. Empirically 2 rows, not 1 — some ancestor's own contribution isn't visible from the
|
||||
// Yoga tree alone (Ink applies at least one more row of vertical offset somewhere between the
|
||||
// computed layout and the actual terminal row), so this constant is tuned to match reality
|
||||
// rather than derived purely from the box's own chrome.
|
||||
// +2 cols for the box's own left border+padding — getAbsolutePosition gives the box's outer
|
||||
// (border) edge, not where its content actually starts; this one is exact (1 border + 1
|
||||
// paddingX column), not empirical.
|
||||
//
|
||||
// The row offset used to be +2 (1 for the box's own top border, +1 unexplained — "some
|
||||
// ancestor's own contribution isn't visible from the Yoga tree alone", found empirically, not
|
||||
// derived) back when App.tsx nested this box inside an extra `bottomSectionRef` wrapper Box and
|
||||
// a fixed-height virtual-scroll viewport. Both are gone now (App.tsx moved history into Ink's
|
||||
// own <Static>, so this box sits directly in a plain live-region Box) — dropped back to +1,
|
||||
// the part that's actually justified by this box's own top border. Unverified in a real
|
||||
// terminal (this environment has none) — if the cursor's still off by a row, that "mystery"
|
||||
// contribution may not be fully gone.
|
||||
setCursorPosition({
|
||||
x: origin.x + 2 + col,
|
||||
y: origin.y + 2 + row,
|
||||
y: origin.y + 1 + row,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,23 +200,66 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
replaceValue(value.slice(0, mention.start), mention.start);
|
||||
}
|
||||
|
||||
function handleSubmit(raw: string) {
|
||||
// Enter while the picker is open accepts the highlighted file instead of sending the message.
|
||||
if (matches.length > 0) {
|
||||
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
|
||||
return;
|
||||
// --- Bracketed paste support ---
|
||||
// Terminals wrap pasted text in \x1b[200~ ... \x1b[201~. Ink delivers these as raw escape
|
||||
// sequences in `input` (not as key sequences). We buffer text between the markers and insert
|
||||
// it as a single multiline string, allowing newlines through instead of treating Enter as submit.
|
||||
const pasteBufferRef = useRef("");
|
||||
const inPasteRef = useRef(false);
|
||||
|
||||
const maybeHandlePaste = useCallback((input: string): boolean => {
|
||||
// Check for bracketed paste start
|
||||
if (input.includes(BP_START)) {
|
||||
const startIdx = input.indexOf(BP_START);
|
||||
const afterStart = input.slice(startIdx + BP_START.length);
|
||||
// Check if the end marker is also in this same input chunk
|
||||
const endIdx = afterStart.indexOf(BP_END);
|
||||
if (endIdx !== -1) {
|
||||
// Complete paste in one chunk
|
||||
const pasted = afterStart.slice(0, endIdx).replace(/\r\n/g, "\n");
|
||||
if (pasted) {
|
||||
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
|
||||
}
|
||||
const remaining = afterStart.slice(endIdx + BP_END.length);
|
||||
if (remaining) maybeHandlePaste(remaining);
|
||||
return true;
|
||||
}
|
||||
// Paste started but not ended in this chunk — start buffering
|
||||
inPasteRef.current = true;
|
||||
const pasted = afterStart.replace(/\r\n/g, "\n");
|
||||
pasteBufferRef.current = pasted;
|
||||
return true;
|
||||
}
|
||||
setHistoryIndex(-1);
|
||||
setTempValue("");
|
||||
onSubmit(raw);
|
||||
}
|
||||
// Check for bracketed paste end while buffering
|
||||
if (inPasteRef.current) {
|
||||
if (input.includes(BP_END)) {
|
||||
const endIdx = input.indexOf(BP_END);
|
||||
pasteBufferRef.current += input.slice(0, endIdx).replace(/\r\n/g, "\n");
|
||||
const pasted = pasteBufferRef.current;
|
||||
const remaining = input.slice(endIdx + BP_END.length);
|
||||
pasteBufferRef.current = "";
|
||||
inPasteRef.current = false;
|
||||
if (pasted) {
|
||||
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
|
||||
}
|
||||
if (remaining) maybeHandlePaste(remaining);
|
||||
return true;
|
||||
}
|
||||
// Still in paste mode — keep buffering
|
||||
pasteBufferRef.current += input.replace(/\r\n/g, "\n");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [value, cursorOffset, replaceValue]);
|
||||
|
||||
useInput((input, key) => {
|
||||
// Shift+Tab: cycle permission mode (takes priority even while suggestions are open).
|
||||
if (key.shift && key.tab) {
|
||||
onCyclePermMode?.();
|
||||
return;
|
||||
}
|
||||
// Shift+Tab (cycle permission mode) is handled globally in App.tsx now, not here — see its
|
||||
// useInput handler for why.
|
||||
if (key.shift && key.tab) return;
|
||||
|
||||
// --- Bracketed paste ---
|
||||
if (maybeHandlePaste(input)) return;
|
||||
|
||||
if (key.escape) {
|
||||
cancelMention();
|
||||
return;
|
||||
@@ -205,8 +298,22 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
}
|
||||
|
||||
// --- Text editing (replaces ink-text-input, see cursorOffset above) ---
|
||||
// Shift+Enter inserts a newline (multiline input)
|
||||
if (key.return && key.shift) {
|
||||
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
|
||||
return;
|
||||
}
|
||||
// Plain Enter: submits if single-line, inserts newline if already multiline
|
||||
if (key.return) {
|
||||
handleSubmit(value);
|
||||
if (value.includes("\n")) {
|
||||
// Multiline input: Enter inserts newline. Ctrl+Enter or Shift+Enter submits.
|
||||
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
|
||||
return;
|
||||
}
|
||||
// Single-line: Enter submits
|
||||
setHistoryIndex(-1);
|
||||
setTempValue("");
|
||||
onSubmit(value);
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
@@ -231,7 +338,10 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
if (input) {
|
||||
replaceValue(value.slice(0, cursorOffset) + input + value.slice(cursorOffset), cursorOffset + input.length);
|
||||
}
|
||||
});
|
||||
}, { isActive });
|
||||
|
||||
// Render multiline input: show newlines as actual line breaks in the text display
|
||||
const displayValue = value;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
@@ -256,8 +366,8 @@ export function ChatInput({ value, onChange, onSubmit, onCyclePermMode, cwd, his
|
||||
)}
|
||||
<Box ref={boxRef} borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
|
||||
<Text color={ACCENT_HEX}>{"> "}</Text>
|
||||
<Text>{value}</Text>
|
||||
<Text>{displayValue}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pairHunk, clip } from "./DiffView.js";
|
||||
|
||||
describe("pairHunk", () => {
|
||||
it("pairs a deletion immediately followed by an addition as one change row", () => {
|
||||
const rows = pairHunk(["-old", "+new"]);
|
||||
expect(rows).toEqual([{ left: "old", leftKind: "del", right: "new", rightKind: "add" }]);
|
||||
});
|
||||
|
||||
it("treats context lines as both sides", () => {
|
||||
const rows = pairHunk([" ctx"]);
|
||||
expect(rows).toEqual([{ left: "ctx", leftKind: "ctx", right: "ctx", rightKind: "ctx" }]);
|
||||
});
|
||||
|
||||
it("pads a pure deletion with an empty right", () => {
|
||||
const rows = pairHunk(["-gone", "-also"]);
|
||||
expect(rows).toEqual([
|
||||
{ left: "gone", leftKind: "del", right: "", rightKind: "empty" },
|
||||
{ left: "also", leftKind: "del", right: "", rightKind: "empty" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pads a pure insertion with an empty left", () => {
|
||||
const rows = pairHunk(["+ins"]);
|
||||
expect(rows).toEqual([{ left: "", leftKind: "empty", right: "ins", rightKind: "add" }]);
|
||||
});
|
||||
|
||||
it("renders a no-newline marker line as context", () => {
|
||||
const rows = pairHunk(["\\ No newline at end of file"]);
|
||||
expect(rows[0]?.leftKind).toBe("ctx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clip", () => {
|
||||
it("returns the string unchanged when it fits", () => {
|
||||
expect(clip("hi", 10)).toBe("hi");
|
||||
});
|
||||
it("truncates with an ellipsis when it overflows", () => {
|
||||
expect(clip("abcdefghij", 5)).toBe("abcd…");
|
||||
});
|
||||
it("returns empty for non-positive width", () => {
|
||||
expect(clip("x", 0)).toBe("");
|
||||
expect(clip("x", -1)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import type { ReactNode } from "react";
|
||||
import { DIFF_ADD_HEX, DIFF_REMOVE_HEX } from "../theme.js";
|
||||
|
||||
/** A single row of a side-by-side diff: the old (left) and new (right) versions of one line, plus
|
||||
* each side's kind so the renderer can color it. `empty` is a padding row used when one side has a
|
||||
* line the other doesn't (a pure insertion or deletion). */
|
||||
interface DiffRow {
|
||||
left: string;
|
||||
leftKind: "ctx" | "add" | "del" | "empty";
|
||||
right: string;
|
||||
rightKind: "ctx" | "add" | "del" | "empty";
|
||||
}
|
||||
|
||||
/** Splits a unified-diff hunk body (the lines after a `@@ … @@` header, each starting with ` `,
|
||||
* `+`, or `-`) into paired old/new rows. A deletion immediately followed by an addition is treated
|
||||
* as a change and shown on one row (old left, new right); runs of pure deletions or additions are
|
||||
* padded with `empty` on the opposite side so the two columns stay aligned. Exported for testing. */
|
||||
export function pairHunk(body: string[]): DiffRow[] {
|
||||
const rows: DiffRow[] = [];
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const line = body[i] ?? "";
|
||||
const tag = line[0];
|
||||
const rest = line.slice(1);
|
||||
if (tag === " ") {
|
||||
rows.push({ left: rest, leftKind: "ctx", right: rest, rightKind: "ctx" });
|
||||
} else if (tag === "-") {
|
||||
const next = body[i + 1] ?? "";
|
||||
if (next[0] === "+") {
|
||||
rows.push({ left: rest, leftKind: "del", right: next.slice(1), rightKind: "add" });
|
||||
i++; // consume the paired addition
|
||||
} else {
|
||||
rows.push({ left: rest, leftKind: "del", right: "", rightKind: "empty" });
|
||||
}
|
||||
} else if (tag === "+") {
|
||||
// An addition not preceded by a paired deletion (the deletion branch above consumes its
|
||||
// paired addition) — a pure insertion.
|
||||
rows.push({ left: "", leftKind: "empty", right: rest, rightKind: "add" });
|
||||
} else {
|
||||
// Blank or unexpected line (e.g. a trailing "\ No newline at end of file") — render as context.
|
||||
rows.push({ left: line, leftKind: "ctx", right: line, rightKind: "ctx" });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Truncates `s` to `width` columns with an ellipsis when it overflows, so the two side-by-side
|
||||
* columns stay row-aligned even when a line is longer than half the terminal. Exported for testing. */
|
||||
export function clip(s: string, width: number): string {
|
||||
if (width <= 0) return "";
|
||||
if (s.length <= width) return s;
|
||||
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
function colorFor(kind: DiffRow["leftKind"]): string | undefined {
|
||||
if (kind === "add") return DIFF_ADD_HEX;
|
||||
if (kind === "del") return DIFF_REMOVE_HEX;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Renders a unified-diff string with color-coded lines: additions green, removals red, hunk
|
||||
* headers (`@@ … @@`) and the `Index:`/`--- `/`+++ ` file headers dimmed. In `sideBySide` mode the
|
||||
* old and new versions are shown in two columns (old left, new right, separated by a dim ` │ `),
|
||||
* with deletions red on the left and additions green on the right — easier to compare at a glance
|
||||
* than a unified diff, at the cost of horizontal space. Used both for the pre-confirmation
|
||||
* permission preview and the post-confirmation scrollback entry. */
|
||||
export function DiffView({ diff, sideBySide = false }: { diff: string; sideBySide?: boolean }) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const lines = diff.split("\n");
|
||||
|
||||
if (!sideBySide) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, i) => {
|
||||
let color: string | undefined;
|
||||
let dim = false;
|
||||
const c = line[0];
|
||||
if (c === "+" && !line.startsWith("+++")) {
|
||||
color = DIFF_ADD_HEX;
|
||||
} else if (c === "-" && !line.startsWith("---")) {
|
||||
color = DIFF_REMOVE_HEX;
|
||||
} else if (c === "@") {
|
||||
dim = true; // hunk header
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
dim = true; // file headers
|
||||
}
|
||||
return (
|
||||
<Text key={i} color={color} dimColor={dim}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Side-by-side: reserve 3 columns for the " │ " gutter; split the rest evenly. Guard against the
|
||||
// no-stdout / very-narrow case so a tiny terminal doesn't produce negative widths. Each cell keeps
|
||||
// a 1-column sign prefix (` ` context, `-` deletion, `+` addition) so the kind is legible without
|
||||
// relying on color alone.
|
||||
const colWidth = Math.max(10, Math.floor((columns - 3) / 2));
|
||||
|
||||
// Walk the unified diff and group lines under their hunk headers, pairing each hunk body into rows.
|
||||
const rendered: ReactNode[] = [];
|
||||
let hunkBody: string[] = [];
|
||||
let hunkIdx = 0;
|
||||
const flushHunk = () => {
|
||||
if (!hunkBody.length) return;
|
||||
const rows = pairHunk(hunkBody);
|
||||
rows.forEach((row, r) => {
|
||||
const key = `${hunkIdx}-${r}`;
|
||||
rendered.push(
|
||||
<Box key={key} flexDirection="row">
|
||||
<Text color={colorFor(row.leftKind)} dimColor={row.leftKind === "ctx"}>
|
||||
{row.leftKind === "empty" ? "" : `${row.leftKind === "del" ? "-" : " "}${clip(row.left, colWidth - 1)}`}
|
||||
</Text>
|
||||
<Text dimColor> │ </Text>
|
||||
<Text color={colorFor(row.rightKind)} dimColor={row.rightKind === "ctx"}>
|
||||
{row.rightKind === "empty" ? "" : `${row.rightKind === "add" ? "+" : " "}${clip(row.right, colWidth - 1)}`}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
hunkBody = [];
|
||||
hunkIdx++;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`h${hunkIdx}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else if (line.startsWith("Index:") || line.startsWith("---") || line.startsWith("+++")) {
|
||||
flushHunk();
|
||||
rendered.push(
|
||||
<Text key={`f${rendered.length}`} dimColor>
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
hunkBody.push(line);
|
||||
}
|
||||
}
|
||||
flushHunk();
|
||||
|
||||
return <Box flexDirection="column">{rendered}</Box>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTree, flattenTree } from "./FilePanel.js";
|
||||
|
||||
function names(lines: ReturnType<typeof flattenTree>): string[] {
|
||||
return lines.map((l) => `${" ".repeat(l.depth)}${l.isDir ? `${l.name}/` : l.name}`);
|
||||
}
|
||||
|
||||
describe("buildTree / flattenTree", () => {
|
||||
it("nests files under their directories, directories sorted before files", () => {
|
||||
const tree = buildTree(["src/index.ts", "src/utils/foo.ts", "README.md"]);
|
||||
const lines = flattenTree(tree);
|
||||
expect(names(lines)).toEqual(["src/", " utils/", " foo.ts", " index.ts", "README.md"]);
|
||||
});
|
||||
|
||||
it("sorts directories before files, alphabetically within each group", () => {
|
||||
const tree = buildTree(["z.ts", "a.ts", "zdir/x.ts", "adir/x.ts"]);
|
||||
const lines = flattenTree(tree);
|
||||
expect(names(lines)).toEqual(["adir/", " x.ts", "zdir/", " x.ts", "a.ts", "z.ts"]);
|
||||
});
|
||||
|
||||
it("produces stable, unique keys per full relative path", () => {
|
||||
const tree = buildTree(["a/x.ts", "b/x.ts"]);
|
||||
const keys = flattenTree(tree).map((l) => l.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(keys).toContain("a/x.ts");
|
||||
expect(keys).toContain("b/x.ts");
|
||||
});
|
||||
|
||||
it("handles an empty file list", () => {
|
||||
expect(flattenTree(buildTree([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks a directory as isDir with collapsed=false by default", () => {
|
||||
const tree = buildTree(["src/index.ts"]);
|
||||
const [srcLine] = flattenTree(tree);
|
||||
expect(srcLine).toMatchObject({ key: "src", isDir: true, collapsed: false });
|
||||
});
|
||||
|
||||
it("omits a collapsed directory's children but keeps its own line", () => {
|
||||
const tree = buildTree(["src/index.ts", "src/utils/foo.ts", "README.md"]);
|
||||
const lines = flattenTree(tree, new Set(["src"]));
|
||||
expect(names(lines)).toEqual(["src/", "README.md"]);
|
||||
expect(lines.find((l) => l.key === "src")).toMatchObject({ collapsed: true });
|
||||
});
|
||||
|
||||
it("collapsing a directory doesn't affect a sibling directory's expansion", () => {
|
||||
const tree = buildTree(["a/x.ts", "b/y.ts"]);
|
||||
const lines = flattenTree(tree, new Set(["a"]));
|
||||
expect(names(lines)).toEqual(["a/", "b/", " y.ts"]);
|
||||
});
|
||||
|
||||
it("collapsing a nested directory only hides its own subtree", () => {
|
||||
const tree = buildTree(["src/utils/a.ts", "src/utils/b.ts", "src/index.ts"]);
|
||||
const lines = flattenTree(tree, new Set(["src/utils"]));
|
||||
expect(names(lines)).toEqual(["src/", " utils/", " index.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Box, Text, useBoxMetrics, useInput, type DOMElement } from "ink";
|
||||
import fg from "fast-glob";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
|
||||
export type FilePanelTab = "files" | "activity";
|
||||
|
||||
export interface TouchedFile {
|
||||
/** Relative to cwd — what's actually shown, so it stays legible regardless of where the project
|
||||
* lives on disk. */
|
||||
relPath: string;
|
||||
status: "read" | "written" | "edited";
|
||||
/** How many times this file has been touched this session — shown as "(N)" past the first. */
|
||||
count: number;
|
||||
lastTouchedAt: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
activeTab: FilePanelTab;
|
||||
cwd: string;
|
||||
touchedFiles: TouchedFile[];
|
||||
width: number;
|
||||
/** Explicit height (in rows) for the panel — it's no longer nested inside a fixed-height
|
||||
* ancestor (App.tsx's history now flows into the terminal's own scrollback via <Static>
|
||||
* rather than a bounded viewport), so this panel sizes itself instead of inheriting a height
|
||||
* to flexGrow against. */
|
||||
height: number;
|
||||
/** Whether the panel currently owns keyboard input (App.tsx disables ChatInput's own useInput
|
||||
* while this is true, so the same arrow/Enter/Escape keystroke doesn't do both at once). */
|
||||
focused: boolean;
|
||||
/** Escape while focused calls this to hand keyboard focus back to the chat input — the panel
|
||||
* itself stays visible, only `focused` flips. */
|
||||
onExitFocus: () => void;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<TouchedFile["status"], string> = { read: "gray", written: "green", edited: "yellow" };
|
||||
const STATUS_GLYPH: Record<TouchedFile["status"], string> = { read: "·", written: "+", edited: "~" };
|
||||
|
||||
interface TreeNode {
|
||||
children: Map<string, TreeNode>;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
export function buildTree(paths: string[]): TreeNode {
|
||||
const root: TreeNode = { children: new Map(), isDir: true };
|
||||
for (const filePath of paths) {
|
||||
const parts = filePath.split("/");
|
||||
let node = root;
|
||||
parts.forEach((part, i) => {
|
||||
const isLast = i === parts.length - 1;
|
||||
let child = node.children.get(part);
|
||||
if (!child) {
|
||||
child = { children: new Map(), isDir: !isLast };
|
||||
node.children.set(part, child);
|
||||
}
|
||||
node = child;
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export interface TreeLine {
|
||||
/** Full path relative to cwd — unique per line, and what collapsedPaths/selection key on. */
|
||||
key: string;
|
||||
depth: number;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
/** Only meaningful when isDir — whether this directory's children are hidden. */
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
/** Directories first, then alphabetical within each group — matches how most file explorers sort.
|
||||
* A collapsed directory's own line is still emitted (so it stays selectable/expandable) but its
|
||||
* children are skipped entirely, the same way a real file explorer hides them. */
|
||||
export function flattenTree(node: TreeNode, collapsedPaths: ReadonlySet<string> = new Set(), prefix = "", depth = 0): TreeLine[] {
|
||||
const entries = [...node.children.entries()].sort(([nameA, a], [nameB, b]) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
const lines: TreeLine[] = [];
|
||||
for (const [name, child] of entries) {
|
||||
const fullPath = prefix ? `${prefix}/${name}` : name;
|
||||
const collapsed = child.isDir && collapsedPaths.has(fullPath);
|
||||
lines.push({ key: fullPath, depth, name, isDir: child.isDir, collapsed });
|
||||
if (child.isDir && !collapsed) {
|
||||
lines.push(...flattenTree(child, collapsedPaths, fullPath, depth + 1));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Same ignore list ChatInput's `@`-mention picker uses (see getActiveMention/fg call there) —
|
||||
// keeps the two file listings consistent rather than drifting apart independently.
|
||||
const IGNORE = ["node_modules/**", ".git/**", "dist/**"];
|
||||
// A very large repo could produce tens of thousands of paths; capping keeps the tree-build/sort/
|
||||
// flatten cheap. Collapsing directories (this component's whole point) is the actual answer to a
|
||||
// tree that's too big to show at once — this cap is just a hard ceiling under that.
|
||||
const MAX_FILES = 2000;
|
||||
|
||||
export function FilePanel({ visible, activeTab, cwd, touchedFiles, width, height, focused, onExitFocus }: Props) {
|
||||
const [allFiles, setAllFiles] = useState<string[] | null>(null);
|
||||
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const scrollRef = useRef<DOMElement | null>(null);
|
||||
const { height: measuredScrollHeight } = useBoxMetrics(scrollRef);
|
||||
const visibleRows = Math.max(1, measuredScrollHeight);
|
||||
|
||||
// Fetched lazily on first show (not on mount) and cached for the rest of the session — the panel
|
||||
// itself stays mounted at all times (hidden via display:none below) specifically so this cache
|
||||
// survives toggling the panel off and back on, rather than re-globbing the project every time.
|
||||
useEffect(() => {
|
||||
if (!visible || allFiles !== null) return;
|
||||
let cancelled = false;
|
||||
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: IGNORE })
|
||||
.then((files) => {
|
||||
if (!cancelled) setAllFiles(files.sort().slice(0, MAX_FILES));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAllFiles([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, allFiles, cwd]);
|
||||
|
||||
const treeLines = useMemo(() => (allFiles ? flattenTree(buildTree(allFiles), collapsedPaths) : []), [allFiles, collapsedPaths]);
|
||||
const lineCount = activeTab === "files" ? treeLines.length : touchedFiles.length;
|
||||
|
||||
// Fresh list each time you switch tabs — a leftover selection/scroll position from the other
|
||||
// tab's (usually different-length) list would either point at the wrong row or be out of range.
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setScrollTop(0);
|
||||
}, [activeTab]);
|
||||
|
||||
// Clamp on every shrink (collapsing a directory, or the file list finishing its first fetch)
|
||||
// rather than just when growing, so a collapse that removes the selected row doesn't leave
|
||||
// selectedIndex pointing past the new end of the list.
|
||||
useEffect(() => {
|
||||
setSelectedIndex((i) => Math.max(0, Math.min(i, lineCount - 1)));
|
||||
}, [lineCount]);
|
||||
|
||||
// Keeps the selected row inside the currently-scrolled window — same marginTop-shift technique
|
||||
// App.tsx's history viewport uses (see effectiveScrollTop there), just driven by selection moving
|
||||
// instead of new content arriving.
|
||||
useEffect(() => {
|
||||
setScrollTop((top) => {
|
||||
if (selectedIndex < top) return selectedIndex;
|
||||
if (selectedIndex >= top + visibleRows) return selectedIndex - visibleRows + 1;
|
||||
return top;
|
||||
});
|
||||
}, [selectedIndex, visibleRows]);
|
||||
|
||||
function toggleCollapse(dirPath: string) {
|
||||
setCollapsedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dirPath)) next.delete(dirPath);
|
||||
else next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) {
|
||||
onExitFocus();
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(lineCount - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "files") return;
|
||||
const line = treeLines[selectedIndex];
|
||||
if (!line?.isDir) return;
|
||||
if (key.return) {
|
||||
toggleCollapse(line.key);
|
||||
} else if (key.leftArrow && !line.collapsed) {
|
||||
toggleCollapse(line.key);
|
||||
} else if (key.rightArrow && line.collapsed) {
|
||||
toggleCollapse(line.key);
|
||||
}
|
||||
},
|
||||
{ isActive: visible && focused },
|
||||
);
|
||||
|
||||
return (
|
||||
// display "none" (not conditional mounting) so the glob fetch above only ever runs once per
|
||||
// session regardless of how many times the panel is toggled — see the effect's comment.
|
||||
<Box
|
||||
display={visible ? "flex" : "none"}
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
width={width}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderColor={focused ? ACCENT_HEX : "gray"}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text>
|
||||
<Text bold color={activeTab === "files" ? ACCENT_HEX : undefined}>
|
||||
Files
|
||||
</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text bold color={activeTab === "activity" ? ACCENT_HEX : undefined}>
|
||||
Activity
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>{"─".repeat(Math.max(1, width - 4))}</Text>
|
||||
<Box ref={scrollRef} flexDirection="column" flexGrow={1} overflowY="hidden">
|
||||
{/* flexShrink={0} is load-bearing the same way it is in App.tsx's history content box:
|
||||
* without it, Yoga shrinks this box (and every line inside it) to fit the panel's height
|
||||
* instead of letting overflowY:hidden clip it, which renders as scrambled/decimated lines
|
||||
* rather than a clean top slice. */}
|
||||
<Box flexDirection="column" flexShrink={0} marginTop={-scrollTop}>
|
||||
{activeTab === "files" ? (
|
||||
allFiles === null ? (
|
||||
<Text dimColor>Loading…</Text>
|
||||
) : treeLines.length === 0 ? (
|
||||
<Text dimColor>No files found.</Text>
|
||||
) : (
|
||||
treeLines.map((line, i) => {
|
||||
const isSelected = focused && i === selectedIndex;
|
||||
const chevron = line.isDir ? (line.collapsed ? "▸ " : "▾ ") : " ";
|
||||
const label = line.isDir ? `${line.name}/` : line.name;
|
||||
return (
|
||||
<Text key={line.key} color={isSelected ? ACCENT_HEX : line.isDir ? "cyan" : undefined} bold={isSelected || line.isDir}>
|
||||
{isSelected ? "❯" : " "}
|
||||
{" ".repeat(line.depth)}
|
||||
{chevron}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : touchedFiles.length === 0 ? (
|
||||
<Text dimColor>No files touched yet.</Text>
|
||||
) : (
|
||||
touchedFiles.map((f, i) => {
|
||||
const isSelected = focused && i === selectedIndex;
|
||||
return (
|
||||
<Text key={f.relPath} color={isSelected ? ACCENT_HEX : undefined} bold={isSelected}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={STATUS_COLOR[f.status]}>{STATUS_GLYPH[f.status]}</Text> {f.relPath}
|
||||
{f.count > 1 ? <Text dimColor> ({f.count})</Text> : null}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Text dimColor>{"─".repeat(Math.max(1, width - 4))}</Text>
|
||||
<Text dimColor>{focused ? "↑↓ move · ↵/←/→ expand · Esc unfocus" : "Ctrl+G tab · Ctrl+F focus/hide"}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { renderMarkdown } from "../render.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { HistoryItem } from "./types.js";
|
||||
@@ -15,20 +16,27 @@ const HELP_LINES = [
|
||||
" /permissions list mutating tools allowed for the rest of this session",
|
||||
" /sessions list saved conversations you can resume with --resume",
|
||||
" /mcp show connected MCP servers and their tool counts",
|
||||
" /mcp reconnect re-connect to configured MCP servers (after editing .mcp.json or restarting one)",
|
||||
" /plugins show installed Claude Code-compatible plugins (commands, agents, MCP servers)",
|
||||
" /hooks show configured hooks per lifecycle event",
|
||||
" /skills show installed skills; /<skill-name> [request] invokes one directly",
|
||||
" /compact summarize the conversation now to free up context",
|
||||
" /export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)",
|
||||
"save the conversation as markdown (or /export json [file] for a JSON dump) — editable filename prompt",
|
||||
" /import <path> [caption] attach a local file or image to your next message",
|
||||
" /clear clear conversation history",
|
||||
" /help show this help",
|
||||
" /exit, /quit exit",
|
||||
"",
|
||||
"Keyboard shortcuts:",
|
||||
" Esc interrupt the current response",
|
||||
" Shift+Tab cycle permission mode",
|
||||
" Ctrl+O print the full text of the last /compact summary",
|
||||
" Ctrl+B background the currently-running bash command",
|
||||
" Ctrl+F open/focus the file panel; press again to close it (Esc to unfocus without closing)",
|
||||
" Ctrl+G switch the file panel's tab (Files / Activity)",
|
||||
" ↑↓ ↵ ← → (while the file panel is focused) navigate / expand / collapse folders",
|
||||
" Mouse wheel / click+drag your terminal's own native scroll and text selection/copy — locode",
|
||||
" doesn't intercept the mouse, so this always works like any other CLI",
|
||||
];
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -41,7 +49,12 @@ function formatDuration(ms: number): string {
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
// Memoized: staticItems only ever grows by appending, so every previously-committed item keeps
|
||||
// the same object reference across re-renders. Without this, every historical item (including
|
||||
// "assistant" ones, whose renderMarkdown call is real CPU work) gets its render function
|
||||
// re-invoked on every parent re-render — which happens on every ThinkingIndicator spinner tick
|
||||
// and every throttled streaming-text frame — even though nothing about that item changed.
|
||||
export const HistoryItemView = memo(function HistoryItemView({ item, width }: { item: HistoryItem; width?: number }) {
|
||||
switch (item.kind) {
|
||||
case "banner":
|
||||
return (
|
||||
@@ -172,7 +185,18 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
);
|
||||
|
||||
case "assistant":
|
||||
return <Text>{renderMarkdown(item.text)}</Text>;
|
||||
return <Text>{renderMarkdown(item.text, width)}</Text>;
|
||||
|
||||
case "thinking":
|
||||
// Dimmed, collapsible-style rendering for reasoning/thinking blocks
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>
|
||||
<Text bold dimColor>Thinking:</Text>
|
||||
{" "}{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Deliberately NOT markdown-rendered while still streaming, unlike the finished "assistant"
|
||||
// case above. marked-terminal re-wraps the *entire* accumulated text from scratch on every
|
||||
@@ -219,8 +243,8 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
{item.todos.length === 0 ? (
|
||||
<Text dimColor>Todos: (cleared)</Text>
|
||||
) : (
|
||||
item.todos.map((t, i) => (
|
||||
<Text key={i} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
item.todos.map((t) => (
|
||||
<Text key={t.content} color={color[t.status]} dimColor={t.status === "pending"} strikethrough={t.status === "completed"}>
|
||||
{" "}
|
||||
{icon[t.status]} {t.content}
|
||||
</Text>
|
||||
@@ -395,4 +419,4 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Box, Text } from "ink";
|
||||
import SelectInput from "ink-select-input";
|
||||
import type { PermissionDecision } from "../../permissions/types.js";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import { looksLikeDiff } from "../../utils/diff.js";
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
@@ -33,15 +35,23 @@ function PermissionItem({ isSelected, label }: { isSelected?: boolean; label?: s
|
||||
}
|
||||
|
||||
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
|
||||
// Color-code the preview when it is a real diff (additions green, removals red, hunk/file
|
||||
// headers dimmed) - the diff is the single most important thing the user evaluates before
|
||||
// approving a mutating tool, so rendering it as plain monochrome text throws away signal.
|
||||
// Falls back to the old line-by-line render for non-diff previews (a bash command, "Create
|
||||
// new file ...", JSON args, etc.).
|
||||
const isDiff = looksLikeDiff(preview);
|
||||
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
|
||||
<Text bold>{toolName}</Text>
|
||||
<Text> </Text>
|
||||
{previewLines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
{isDiff && preview ? (
|
||||
<DiffView diff={preview} />
|
||||
) : (
|
||||
previewLines.map((line, i) => <Text key={i}>{line}</Text>)
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text>Do you want to proceed?</Text>
|
||||
<SelectInput
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
import type { PermissionMode } from "../../permissions/types.js";
|
||||
import type { GitInfo } from "../../utils/gitInfo.js";
|
||||
@@ -32,6 +32,7 @@ interface Props {
|
||||
createdAt: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
modelTimeMs: number;
|
||||
gitInfo: GitInfo | null;
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ function formatElapsed(ms: number): string {
|
||||
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
export const StatusBar = memo(function StatusBar({
|
||||
model,
|
||||
mode,
|
||||
permMode,
|
||||
@@ -75,6 +76,7 @@ export function StatusBar({
|
||||
createdAt,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
modelTimeMs,
|
||||
gitInfo,
|
||||
}: Props) {
|
||||
// Ticks every 30s purely to keep "elapsed"/burn-rate live while otherwise idle — no other state
|
||||
@@ -90,10 +92,18 @@ export function StatusBar({
|
||||
const approx = contextIsEstimate ? "~" : "";
|
||||
|
||||
const elapsedMs = Math.max(0, now - new Date(createdAt).getTime());
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
const elapsedMinutes = elapsedMs / 60_000;
|
||||
const burnRate = elapsedMinutes >= 0.1 ? totalTokens / elapsedMinutes : null;
|
||||
// Use model time (wall-clock time spent waiting on backend responses) rather than session
|
||||
// elapsed time. A session open for 30 minutes with only 2 minutes of actual model interaction
|
||||
// should show the real throughput, not a diluted rate that makes the model look slow.
|
||||
const modelMinutes = modelTimeMs / 60_000;
|
||||
const burnRate = modelMinutes >= 0.1 ? outputTokens / modelMinutes : null;
|
||||
|
||||
// Icons here are deliberately either full RGI emoji (📁 🔑 ⏳ 🔥 — string-width and terminals
|
||||
// agree they're 2 wide) or plain geometric shapes (◆ ▸ — agree they're 1 wide). Avoid
|
||||
// text-default emoji like ⏱ U+23F1 / ⏵ U+23F5: string-width calls them 1 but many terminals
|
||||
// (Windows Terminal, iTerm) render them 2, so a line drifts wider than Ink thinks and the live
|
||||
// region below leaves stranded copies of itself in scrollback on every re-render.
|
||||
return (
|
||||
<Box flexDirection="column" width="100%" paddingX={1}>
|
||||
<Box gap={1}>
|
||||
@@ -118,16 +128,16 @@ export function StatusBar({
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>🔑 {sessionId.slice(0, 8)}</Text>
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>⏱ {formatElapsed(elapsedMs)}</Text>
|
||||
<Text dimColor>⏳ {formatElapsed(elapsedMs)}</Text>
|
||||
<Text dimColor>│</Text>
|
||||
<Text dimColor>🔥 {burnRate === null ? "—" : `${formatTokenCount(burnRate)}/min`}</Text>
|
||||
</Box>
|
||||
<Box gap={1} justifyContent="space-between">
|
||||
<Text color={MODE_COLORS[permMode]}>
|
||||
⏵⏵ {MODE_LABELS[permMode]} (shift+tab to cycle)
|
||||
▸▸ {MODE_LABELS[permMode]} (shift+tab to cycle)
|
||||
</Text>
|
||||
<Text color="yellow">/export · /compact · /help · /exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Text } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import { memo } from "react";
|
||||
import { ACCENT_HEX } from "../theme.js";
|
||||
|
||||
export function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
|
||||
export const ThinkingIndicator = memo(function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
|
||||
return (
|
||||
<Text color={ACCENT_HEX}>
|
||||
<Spinner type="dots" /> {label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+16
-29
@@ -9,6 +9,9 @@ import { buildSkillTool } from "../../plugins/skillTool.js";
|
||||
import type { ToolDef } from "../../tools/types.js";
|
||||
import { flushPendingSaves } from "../../persistence/sessionStore.js";
|
||||
import { killAllBackgroundJobs } from "../../tools/backgroundJobs.js";
|
||||
import { shutdownAll as shutdownAllLspServers } from "../../codeintel/lspManager.js";
|
||||
import { configureLanguageSpecs } from "../../codeintel/lspManager.js";
|
||||
import { resolveLspServers } from "../../config/config.js";
|
||||
import { App } from "./App.js";
|
||||
|
||||
export interface RunInkAppOptions {
|
||||
@@ -29,6 +32,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Apply user-configured LSP server overrides/additions (locode config set lspServers) to the
|
||||
// built-in language→server mappings before any LSP tool is used. Cheap and idempotent.
|
||||
configureLanguageSpecs(resolveLspServers());
|
||||
|
||||
// Kick off MCP server connections immediately so they overlap with model listing/picking —
|
||||
// by the time a session is actually created, this is usually already settled. Plugin-defined
|
||||
// agents (agents/*.md) become tools too, alongside MCP-provided ones, and every installed
|
||||
@@ -45,25 +52,12 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
// Ink render tree so cleanup() can still read it after instance.unmount() has torn everything
|
||||
// in App.tsx down.
|
||||
let currentSessionId: string | null = null;
|
||||
// Whether we've switched to the terminal's alternate screen buffer — tracked so cleanup() only
|
||||
// switches back if we actually switched away, and so a second session starting mid-process
|
||||
// (e.g. picking a different saved session via /resume) doesn't re-enter it redundantly.
|
||||
let enteredAltScreen = false;
|
||||
// Entering the alternate screen alone does not guarantee the cursor starts at the top-left —
|
||||
// some terminals carry the cursor row over from the main screen, leaving blank rows above
|
||||
// Ink's first output until enough content has been printed to push past that row. Clear the
|
||||
// new (blank) buffer and explicitly home the cursor so content always starts flush at (0, 0).
|
||||
const ALT_SCREEN_ENTER = "[?1049h" + "[2J" + "[H";
|
||||
const ALT_SCREEN_EXIT = "[?1049l";
|
||||
|
||||
// Start the UI immediately — no blocking network calls before rendering.
|
||||
// Model listing happens inside the App component so the user sees the UI right away.
|
||||
//
|
||||
// The model-select/connecting phases before a session exists stay on the main screen (so any
|
||||
// startup errors remain in normal scrollback); once a session actually starts, we switch to the
|
||||
// alternate screen buffer for a clean full-screen chat view. This trades away scrollback (no
|
||||
// mouse-wheel/Shift+PgUp scrolling once in the alt screen) for that full-screen feel — a
|
||||
// deliberate choice, revisit if the lack of scrollback turns out to matter more in practice.
|
||||
// Start the UI immediately — no blocking network calls before rendering. Model listing happens
|
||||
// inside the App component so the user sees the UI right away. Deliberately stays on the
|
||||
// terminal's normal screen buffer (never the alternate screen) the whole time — finished
|
||||
// history prints as permanent scrollback (see App.tsx's use of Ink's <Static>), so the
|
||||
// terminal's own native mouse-wheel scroll and click-drag text selection/copy just work, with
|
||||
// no app-side mouse tracking or virtual-scroll machinery needed.
|
||||
const instance = render(
|
||||
<App
|
||||
baseURL={opts.baseURL}
|
||||
@@ -76,10 +70,6 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
extraToolsPromise={extraToolsPromise}
|
||||
onSessionIdChange={(id) => {
|
||||
currentSessionId = id;
|
||||
if (!enteredAltScreen) {
|
||||
enteredAltScreen = true;
|
||||
process.stdout.write(ALT_SCREEN_ENTER);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -92,16 +82,13 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
// Leave the alternate screen first (if we ever entered it) so everything printed below —
|
||||
// hook errors, the resume hint — lands on the user's normal scrollback, not a screen that's
|
||||
// about to disappear.
|
||||
if (enteredAltScreen) {
|
||||
process.stdout.write(ALT_SCREEN_EXIT);
|
||||
}
|
||||
// Flush in-flight autosaves first so a fire-and-forget persist right before exit isn't lost.
|
||||
await flushPendingSaves();
|
||||
// Best-effort: don't leave backgrounded shells (dev servers, watch builds) running as orphans.
|
||||
await killAllBackgroundJobs();
|
||||
// Best-effort: shut down any lazily-spawned LSP servers (tsserver, pyright, gopls,
|
||||
// clangd, rust-analyzer) so they don't outlive locode as orphans.
|
||||
await shutdownAllLspServers().catch(() => {});
|
||||
await disconnectAllMcpServers();
|
||||
// Best-effort — fires on every exit path, so there isn't always a specific session id to attach
|
||||
// (e.g. exiting from the model picker before a session ever started).
|
||||
|
||||
@@ -33,6 +33,7 @@ export type HistoryItem =
|
||||
}
|
||||
| { id: string; kind: "user"; text: string }
|
||||
| { id: string; kind: "assistant"; text: string }
|
||||
| { id: string; kind: "thinking"; text: string }
|
||||
| { id: string; kind: "streaming_text"; text: string }
|
||||
| { id: string; kind: "tool_call"; label: string }
|
||||
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import stringWidth from "string-width";
|
||||
import { renderMarkdown } from "./render.js";
|
||||
|
||||
const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, "");
|
||||
|
||||
describe("renderMarkdown width wrapping", () => {
|
||||
it("holds every line of a wide table within the given width", () => {
|
||||
const md = [
|
||||
"| col a | col b | col c |",
|
||||
"|-------|-------|-------|",
|
||||
"| 압도적 (LangChain, LlamaIndex, 벡터DB 등 거의 다 Python 우선) | 따라가는 중 | x |",
|
||||
"| asyncio 있지만 동기로 시작하면 나중에 고통 | 처음부터 async가 기본 | y |",
|
||||
].join("\n");
|
||||
for (const width of [40, 60, 80, 100]) {
|
||||
const out = renderMarkdown(md, width);
|
||||
for (const line of out.split("\n")) {
|
||||
expect(stringWidth(line), `line "${line}" @${width}`).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hard-breaks a long unbroken line (URL / code) to fit", () => {
|
||||
const url = "https://example.com/" + "a".repeat(300);
|
||||
const out = renderMarkdown(url, 50);
|
||||
expect(out.split("\n").length).toBeGreaterThan(1);
|
||||
for (const line of out.split("\n")) {
|
||||
expect(stringWidth(line)).toBeLessThanOrEqual(50);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves already-narrow prose intact (content preserved)", () => {
|
||||
const out = renderMarkdown("hello **world**", 80);
|
||||
expect(stripAnsi(out)).toContain("hello world");
|
||||
});
|
||||
|
||||
it("does not wrap when no width is given", () => {
|
||||
const md = "a ".repeat(100).trim();
|
||||
const out = renderMarkdown(md);
|
||||
expect(out).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("caches width variants independently (no key collision)", () => {
|
||||
const text = "80 this text starts with digits";
|
||||
const wide = renderMarkdown(text);
|
||||
const narrow = renderMarkdown(text, 80);
|
||||
// A width-less call and a width-80 call on colliding-looking text must not return each other.
|
||||
expect(wide).not.toContain("\n");
|
||||
void narrow;
|
||||
});
|
||||
});
|
||||
+31
-3
@@ -1,11 +1,39 @@
|
||||
import { marked } from "marked";
|
||||
import { markedTerminal } from "marked-terminal";
|
||||
import wrapAnsi from "wrap-ansi";
|
||||
|
||||
// @types/marked-terminal's MarkedExtension shape doesn't line up with the installed marked version;
|
||||
// the two packages are functionally compatible at runtime per marked-terminal's own peer range.
|
||||
marked.use(markedTerminal() as Parameters<typeof marked.use>[0]);
|
||||
//
|
||||
// tableOptions overrides cli-table3's own default header color (style.head: ['red']) — red-on-blue
|
||||
// terminal themes make table headers hard to read. "yellow" is the closest this color scheme
|
||||
// actually supports: it only recognizes standard ANSI 16-color names, and terminals have no true
|
||||
// "orange" in that palette — literal "orange"/hex values are silently accepted but render with no
|
||||
// color at all (verified empirically), so this is the practical substitute, not a compromise pick.
|
||||
marked.use(markedTerminal({ tableOptions: { style: { head: ["yellow"] } } }) as Parameters<typeof marked.use>[0]);
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
// marked.parse() + marked-terminal's ANSI formatting is real CPU work (word-wrap, table layout,
|
||||
// syntax highlighting). Historical assistant messages are immutable once committed, so re-parsing
|
||||
// the same text on every re-render (every spinner tick, every streamed-token frame) is pure waste
|
||||
// that scales with total conversation length — cache by input text so each message is parsed once.
|
||||
const renderCache = new Map<string, string>();
|
||||
|
||||
// marked-terminal (and cli-table3, for tables) emit lines that can be far wider than the terminal —
|
||||
// a wide table, a long code line, a URL. The terminal soft-wraps those, but Ink's <Static> redraw
|
||||
// math counts one physical row per logical line, so anything that soft-wrapped throws off its
|
||||
// accounting: the live region below (status bar + input box) gets stranded copies of itself in
|
||||
// scrollback on every subsequent re-render. Hard-wrapping the finished output to the caller's width
|
||||
// keeps every physical line within bounds so Ink's line count stays exact.
|
||||
export function renderMarkdown(text: string, width?: number): string {
|
||||
const wrapWidth = width && width > 0 ? Math.floor(width) : 0;
|
||||
const key = `${wrapWidth} ${text}`;
|
||||
const cached = renderCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const rendered = marked.parse(text);
|
||||
return typeof rendered === "string" ? rendered.trimEnd() : text;
|
||||
let result = typeof rendered === "string" ? rendered.trimEnd() : text;
|
||||
if (wrapWidth > 0) {
|
||||
result = wrapAnsi(result, wrapWidth, { hard: true, trim: false });
|
||||
}
|
||||
renderCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const ACCENT_HEX = "#D97757";
|
||||
export const DIFF_ADD_HEX = "#2ea043";
|
||||
export const DIFF_REMOVE_HEX = "#e8904e";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { looksLikeDiff } from "./diff.js";
|
||||
|
||||
describe("looksLikeDiff", () => {
|
||||
it("recognizes a createPatch unified diff", () => {
|
||||
expect(looksLikeDiff("Index: foo\n--- a\n+++ b\n@@ -1 +1 @@\n-x\n+y\n")).toBe(true);
|
||||
});
|
||||
it("recognizes a leading @@ hunk", () => {
|
||||
expect(looksLikeDiff("@@ -1,2 +1,2 @@\n ctx\n-x\n+y\n")).toBe(true);
|
||||
});
|
||||
it("returns false for a plain bash command preview", () => {
|
||||
expect(looksLikeDiff("rm -rf node_modules")).toBe(false);
|
||||
});
|
||||
it("returns false for empty / undefined", () => {
|
||||
expect(looksLikeDiff("")).toBe(false);
|
||||
expect(looksLikeDiff(undefined)).toBe(false);
|
||||
expect(looksLikeDiff(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** True if `s` looks like a unified diff (the output of `createPatch` from the `diff` package),
|
||||
* vs a plain preview like a bash command line or "Create new file …". Used to decide whether to
|
||||
* render a preview with +/- color coding or as plain text. createPatch output starts with an
|
||||
* `Index:`/`---`/`+++` header and always contains at least one `@@` hunk marker when there's a
|
||||
* real change, so the hunk marker is the most reliable discriminator (a command line containing
|
||||
* a stray `-` won't false-positive). */
|
||||
export function looksLikeDiff(s: string | undefined | null): boolean {
|
||||
if (!s) return false;
|
||||
return s.includes("\n@@") || s.startsWith("@@") || s.startsWith("Index:") || s.startsWith("--- ");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateTokens } from "./tokens.js";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
|
||||
describe("estimateTokens — script-aware heuristic", () => {
|
||||
it("returns a positive number for a single system message", () => {
|
||||
const msgs: ChatCompletionMessageParam[] = [{ role: "system", content: "You are helpful." }];
|
||||
expect(estimateTokens(msgs)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("scales roughly linearly with English prose length", () => {
|
||||
const short: ChatCompletionMessageParam[] = [{ role: "user", content: "hello" }];
|
||||
const long: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "hello ".repeat(100) + "world" },
|
||||
];
|
||||
// ~605 chars vs 5 chars: the long message should be many times larger (overhead aside).
|
||||
expect(estimateTokens(long)).toBeGreaterThan(estimateTokens(short) * 15);
|
||||
});
|
||||
|
||||
it("estimates CJK text as more tokens than the same length of Latin text", () => {
|
||||
// Same character count, but Korean characters each map closer to 1:1 token.
|
||||
const latin: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "a".repeat(40) },
|
||||
];
|
||||
const korean: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "안".repeat(40) },
|
||||
];
|
||||
expect(estimateTokens(korean)).toBeGreaterThan(estimateTokens(latin));
|
||||
});
|
||||
|
||||
it("estimates symbol-heavy (code) text as more tokens than prose of the same length", () => {
|
||||
const prose: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "word word word word word word word word" },
|
||||
];
|
||||
const code: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "{}{}{}{}{}{}{}{}()()()()()()()()[][][][]" },
|
||||
];
|
||||
// Both 39 chars; the code version has more dense-symbol weight.
|
||||
expect(estimateTokens(code)).toBeGreaterThan(estimateTokens(prose));
|
||||
});
|
||||
|
||||
it("accounts for tool_calls structure", () => {
|
||||
const withCalls: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: '{"path":"src/index.ts"}' },
|
||||
},
|
||||
],
|
||||
} as ChatCompletionMessageParam,
|
||||
];
|
||||
expect(estimateTokens(withCalls)).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("accounts for tool result messages", () => {
|
||||
const result: ChatCompletionMessageParam[] = [
|
||||
{ role: "tool", tool_call_id: "call_1", content: "the file contents are here" },
|
||||
] as ChatCompletionMessageParam[];
|
||||
expect(estimateTokens(result)).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("handles multipart content arrays (text parts)", () => {
|
||||
const msgs: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "describe this" },
|
||||
{ type: "text", text: "and this" },
|
||||
],
|
||||
} as ChatCompletionMessageParam,
|
||||
];
|
||||
expect(estimateTokens(msgs)).toBeGreaterThan(
|
||||
estimateTokens([{ role: "user", content: "describe this" }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("charges a flat cost for non-text content parts (images)", () => {
|
||||
const textOnly: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "look" }] } as unknown as ChatCompletionMessageParam,
|
||||
];
|
||||
const withImage: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
||||
],
|
||||
} as unknown as ChatCompletionMessageParam,
|
||||
];
|
||||
expect(estimateTokens(withImage)).toBeGreaterThan(estimateTokens(textOnly));
|
||||
});
|
||||
|
||||
it("grows with more messages, not just longer content", () => {
|
||||
const one: ChatCompletionMessageParam[] = [{ role: "user", content: "ab" }];
|
||||
const two: ChatCompletionMessageParam[] = [
|
||||
{ role: "user", content: "ab" },
|
||||
{ role: "assistant", content: "cd" },
|
||||
];
|
||||
// Each message carries a per-message overhead, so two short messages cost more than one.
|
||||
expect(estimateTokens(two)).toBeGreaterThan(estimateTokens(one));
|
||||
});
|
||||
});
|
||||
+92
-4
@@ -1,8 +1,96 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
||||
|
||||
/** Rough token estimate (~4 chars/token) used when the backend doesn't report real usage stats
|
||||
* (via `stream_options: { include_usage: true }`) or before any turn has run yet. */
|
||||
/**
|
||||
* Per-message structural overhead the chat template adds (role tags, delimiters, etc.).
|
||||
* Most chat templates wrap each message with ~3–5 special tokens (`<|im_start|>role\n`,
|
||||
* `<|im_end|>\n`, etc.), so charge a flat per-message cost regardless of content length.
|
||||
*/
|
||||
const PER_MESSAGE_OVERHEAD = 4;
|
||||
|
||||
/**
|
||||
* Estimate the token count of a chat message array without backend support.
|
||||
*
|
||||
* The old `JSON.stringify(messages).length / 4` heuristic had two failure modes:
|
||||
* 1. It counted the JSON serialization overhead (quotes, braces, escaped delimiters) as
|
||||
* content tokens — inflating the estimate by ~15–25% since those aren't sent to the model.
|
||||
* 2. It used one flat chars-per-token ratio for everything, but that ratio varies a lot by
|
||||
* script: English prose is ~4 chars/token, code/symbols ~3.5, and CJK (Korean/Chinese/
|
||||
* Japanese) is ~1.5 chars/token because each code point is usually its own BPE token.
|
||||
*
|
||||
* This walker reconstructs only the text the model actually sees (system/user content strings,
|
||||
* assistant content, tool calls, tool results) and applies a script-aware ratio. It stays a
|
||||
* pure synchronous estimate — no backend calls — so it's safe to use before the first turn,
|
||||
* after compaction, and when creating sub-agents.
|
||||
*/
|
||||
export function estimateTokens(messages: ChatCompletionMessageParam[]): number {
|
||||
const chars = JSON.stringify(messages).length;
|
||||
return Math.ceil(chars / 4);
|
||||
let tokens = 0;
|
||||
for (const msg of messages) {
|
||||
tokens += PER_MESSAGE_OVERHEAD;
|
||||
tokens += estimateContentTokens(msg);
|
||||
}
|
||||
// A trailing assistant generation sentinel / chat-template end tokens.
|
||||
tokens += 3;
|
||||
return Math.max(1, tokens);
|
||||
}
|
||||
|
||||
function estimateContentTokens(msg: ChatCompletionMessageParam): number {
|
||||
let chars = 0;
|
||||
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
chars += weightedChars(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part == null) continue;
|
||||
if (typeof part === "string") {
|
||||
chars += weightedChars(part);
|
||||
} else if (typeof part === "object") {
|
||||
const p = part as { type?: string; text?: string };
|
||||
// Text parts contribute their text; image/audio parts are a flat structural cost
|
||||
// (the model sees a placeholder image token, not the base64 bytes).
|
||||
if (p.type === "text" && typeof p.text === "string") chars += weightedChars(p.text);
|
||||
else chars += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls: the model emits a JSON-ish structure; estimate its serialized size.
|
||||
const toolCalls = (msg as { tool_calls?: unknown }).tool_calls;
|
||||
if (Array.isArray(toolCalls)) {
|
||||
for (const call of toolCalls) {
|
||||
const c = call as { function?: { name?: string; arguments?: string }; id?: string };
|
||||
if (c.function?.name) chars += weightedChars(c.function.name) + 4;
|
||||
if (c.function?.arguments) chars += weightedChars(c.function.arguments) + 2;
|
||||
if (c.id) chars += weightedChars(c.id) + 2;
|
||||
chars += 6; // call/function wrapper tokens
|
||||
}
|
||||
}
|
||||
|
||||
// Tool result role: the content is the tool output text.
|
||||
// `name` and `tool_call_id` are small metadata fields.
|
||||
const name = (msg as { name?: string }).name;
|
||||
if (name) chars += weightedChars(name) + 2;
|
||||
const toolCallId = (msg as { tool_call_id?: string }).tool_call_id;
|
||||
if (toolCallId) chars += weightedChars(toolCallId) + 2;
|
||||
|
||||
return Math.ceil(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a content string to "effective chars" using a script-aware weight, then divided by a
|
||||
* base chars-per-token ratio to get tokens. The weight encodes that a single CJK code point
|
||||
* is worth roughly one token (ratio ~1.5) while Latin code points cluster ~4 per token, and
|
||||
* dense punctuation/symbols (common in code) are closer to ~3.5.
|
||||
*
|
||||
* We accumulate weighted chars and the caller divides by the base ratio once.
|
||||
*
|
||||
* Regex-based approach: instead of iterating per character, we count CJK and dense-symbol
|
||||
* matches in bulk and assign their weights, then treat the remainder as base weight.
|
||||
*/
|
||||
function weightedChars(text: string): number {
|
||||
const cjk = text.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u1100-\u11ff]/gu)?.length ?? 0;
|
||||
const dense = text.match(/[!-\/:\-@\[-`\{-~]/g)?.length ?? 0;
|
||||
const rest = text.length - cjk - dense;
|
||||
const weight = cjk * 2.4 + dense * 1.15 + rest * 1;
|
||||
return weight / 4; // base ratio
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { truncate } from "./truncate.js";
|
||||
|
||||
describe("truncate — head+tail preservation", () => {
|
||||
it("returns short text unchanged", () => {
|
||||
expect(truncate("hello", 100)).toBe("hello");
|
||||
});
|
||||
|
||||
it("returns text at exactly the limit unchanged", () => {
|
||||
const text = "x".repeat(100);
|
||||
expect(truncate(text, 100)).toBe(text);
|
||||
});
|
||||
|
||||
it("keeps the head and tail and elides the middle", () => {
|
||||
// 50 lines of 10 chars each = 599 chars (49 newlines). Cap at 300.
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `line${String(i).padStart(5, "0")}`);
|
||||
const text = lines.join("\n");
|
||||
const out = truncate(text, 300);
|
||||
|
||||
// Head preserved: first line still present.
|
||||
expect(out).toContain("line00000");
|
||||
// Tail preserved: last line still present.
|
||||
expect(out).toContain("line00049");
|
||||
// Middle dropped: a middle line is gone.
|
||||
expect(out).not.toContain("line00025");
|
||||
// Truncation marker present with a character count.
|
||||
expect(out).toMatch(/\[truncated \d+ more characters/);
|
||||
});
|
||||
|
||||
it("preserves the tail error line a model most needs to see", () => {
|
||||
const lines = Array.from({ length: 200 }, (_, i) => `row ${i}: data`);
|
||||
// Append a final error line — the whole point of keeping the tail.
|
||||
lines.push("Error: compilation failed at line 42");
|
||||
const text = lines.join("\n");
|
||||
const out = truncate(text, 300);
|
||||
expect(out).toContain("Error: compilation failed at line 42");
|
||||
});
|
||||
|
||||
it("cuts on line boundaries, never half a line", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line-${i}-${"x".repeat(40)}`);
|
||||
const text = lines.join("\n");
|
||||
const out = truncate(text, 800);
|
||||
// The head section's last kept line should be a complete line, not a fragment.
|
||||
const firstPart = out.split("\n\n... [truncated")[0]!;
|
||||
for (const line of firstPart.split("\n")) {
|
||||
// Every kept head line should start with the known prefix or be empty.
|
||||
expect(line === "" || /^line-\d+-x+$/.test(line)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a file only slightly over budget without overlapping head and tail", () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`);
|
||||
const text = lines.join("\n");
|
||||
// Budget just under total length so head and tail would overlap without the guard.
|
||||
const out = truncate(text, text.length - 1);
|
||||
// Should still return something readable with a truncation marker.
|
||||
expect(out).toMatch(/\[truncated/);
|
||||
// No duplicate lines: each kept line appears at most once as a *whole line*
|
||||
// (match against line boundaries, not as a substring, since "line 1" is a substring of "line 10").
|
||||
const outLines = out.split("\n");
|
||||
for (const line of lines) {
|
||||
const occurrences = outLines.filter((l) => l === line).length;
|
||||
expect(occurrences).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a character cut when every line is huge", () => {
|
||||
// One massive line longer than the head budget.
|
||||
const text = "x".repeat(5000);
|
||||
const out = truncate(text, 1000);
|
||||
expect(out).toMatch(/\[truncated/);
|
||||
expect(out.length).toBeLessThan(text.length);
|
||||
});
|
||||
});
|
||||
+64
-2
@@ -1,4 +1,66 @@
|
||||
/**
|
||||
* Cap a string to roughly `maxChars` by keeping the head and the tail and eliding the
|
||||
* middle — far more useful for command output than a bare head-only cut, because the tail
|
||||
* usually carries the error/status line a model most needs to see.
|
||||
*
|
||||
* Cuts on line boundaries when possible so the result stays readable, and reports exactly
|
||||
* how many characters were dropped so the model knows there is more it can't see.
|
||||
*/
|
||||
export function truncate(text: string, maxChars = 20_000): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}\n... [truncated ${text.length - maxChars} more characters]`;
|
||||
}
|
||||
|
||||
// Reserve a few lines for the truncation notice itself so the final string doesn't
|
||||
// overshoot maxChars after we splice the marker back in.
|
||||
const NOTICE_SLACK = 120;
|
||||
const budget = Math.max(maxChars - NOTICE_SLACK, Math.floor(maxChars * 0.9));
|
||||
|
||||
// Split into lines so we can cut on boundaries. We keep whole lines for both head and tail,
|
||||
// never half a line — half-lines confuse both the model and the tests.
|
||||
const lines = text.split("\n");
|
||||
|
||||
// First pass: keep ~60% of the budget for the head, ~40% for the tail. The head carries
|
||||
// context and the tail carries the outcome (exit status, error, final summary).
|
||||
const headBudget = Math.floor(budget * 0.6);
|
||||
const tailBudget = budget - headBudget;
|
||||
|
||||
const headLines: string[] = [];
|
||||
let headChars = 0;
|
||||
for (const line of lines) {
|
||||
// +1 accounts for the "\n" we'll rejoin with.
|
||||
if (headChars + line.length + 1 > headBudget) break;
|
||||
headLines.push(line);
|
||||
headChars += line.length + 1;
|
||||
}
|
||||
|
||||
const tailLines: string[] = [];
|
||||
let tailChars = 0;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (tailChars + line.length + 1 > tailBudget) break;
|
||||
// Avoid overlapping with the head when the file is only slightly over budget.
|
||||
if (i < headLines.length) break;
|
||||
tailLines.unshift(line);
|
||||
tailChars += line.length + 1;
|
||||
}
|
||||
|
||||
const keptHead = headLines.join("\n");
|
||||
const keptTail = tailLines.join("\n");
|
||||
const dropped = text.length - (keptHead.length + keptTail.length);
|
||||
|
||||
// Edge case: the file is over budget but every single line is huge (longer than headBudget),
|
||||
// so the loop above kept zero head lines. Fall back to a character cut so we still return
|
||||
// something useful rather than an empty head + the whole tail.
|
||||
if (headLines.length === 0) {
|
||||
const headSlice = text.slice(0, headBudget);
|
||||
const tailSlice = text.slice(text.length - tailBudget);
|
||||
const droppedChars = text.length - headSlice.length - tailSlice.length;
|
||||
return `${headSlice}\n\n... [truncated ${droppedChars} more characters — head+tail preserved, middle omitted] ...\n\n${tailSlice}`;
|
||||
}
|
||||
|
||||
if (tailLines.length === 0) {
|
||||
// Tail couldn't keep anything without overlapping head: just head + notice.
|
||||
return `${keptHead}\n\n... [truncated ${dropped} more characters — tail omitted] ...`;
|
||||
}
|
||||
|
||||
return `${keptHead}\n\n... [truncated ${dropped} more characters — middle omitted, ${headLines.length} head + ${tailLines.length} tail lines kept] ...\n\n${keptTail}`;
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/cli.ts"],
|
||||
format: ["esm"],
|
||||
target: "node20",
|
||||
target: "node22",
|
||||
clean: true,
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
|
||||
Reference in New Issue
Block a user