mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f82f93559b | ||
|
|
5a7d16c24f | ||
|
|
292e51f0c2 | ||
|
|
c5b0906576 | ||
|
|
30ca814809 | ||
|
|
000e7c3ac0 | ||
|
|
a22355a2ab | ||
|
|
39a4e137dc | ||
|
|
0383255de2 | ||
|
|
b2a50d50f5 | ||
|
|
e486a8e23a | ||
|
|
a4c1e9b478 | ||
|
|
9fdbdf738b | ||
|
|
f3f844b3dc | ||
|
|
d65ca4f67f | ||
|
|
ee577995d0 | ||
|
|
e3d6fe3a86 | ||
|
|
bfc4cd38ee | ||
|
|
0ccc419d0d | ||
|
|
7542aca687 | ||
|
|
1e906bb577 | ||
|
|
a8c367ad0c | ||
|
|
2d35cab39e | ||
|
|
874d7df2f7 |
No files matched your search
+200
@@ -3,6 +3,206 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.0] — 2026-04-24
|
||||
|
||||
**Headline:** Cost control becomes a first-class pillar. Default flips
|
||||
flash-first, `v4-pro` is opt-in, tool results auto-compact between
|
||||
turns, and the TUI grows per-turn cost visibility + a `/pro`
|
||||
one-shot upgrade. Month-over-month cost on an active coding project
|
||||
drops ~6–10× in practice.
|
||||
|
||||
### ⚠ Breaking (behavior, not API)
|
||||
|
||||
- **Default model is now `deepseek-v4-flash`**, not `deepseek-v4-pro`.
|
||||
`reasonix code`, `reasonix chat`, and subagents all land on flash
|
||||
by default. Users who need the frontier tier:
|
||||
`/preset max`, `/pro`, or `--model deepseek-v4-pro` on CLI.
|
||||
- **Preset defaults changed**. None of the three presets auto-enable
|
||||
`branch` or `harvest` anymore — both were hidden multipliers. The
|
||||
new matrix:
|
||||
| preset | model | effort | harvest | branch |
|
||||
|---|---|---|---|---|
|
||||
| fast | v4-flash | high | off | 1 |
|
||||
| smart (default) | v4-flash | max | off | 1 |
|
||||
| max | v4-pro | max | off | 1 |
|
||||
Users who want branching still get it via `/branch N`; users who
|
||||
want harvest still get it via `/harvest on`. Neither is implicit.
|
||||
- **Default preset is now `smart` (was `fast`).** Flash + full
|
||||
thinking budget is the best price/quality point for coding.
|
||||
- **`deepseek-chat` / `deepseek-reasoner` aliases scheduled for
|
||||
removal.** Still accepted (they map to flash non-thinking /
|
||||
thinking), but every user-facing surface (`/models`, setup wizard,
|
||||
`--help`) now advertises `v4-flash` / `v4-pro` only.
|
||||
|
||||
### Added — Cost control (Pillar 4)
|
||||
|
||||
- **`/pro` single-turn arming** — queue v4-pro for just the next
|
||||
turn; auto-disarms after. Separate from `/preset max` (persistent)
|
||||
so "this one task is hard" doesn't require a preset round-trip.
|
||||
Status bar shows `⇧ pro armed` in yellow while queued, `⇧ pro
|
||||
escalated` in red while the turn is actually running on pro.
|
||||
- **Failure-triggered auto-escalation** — the loop tracks
|
||||
`edit_file` SEARCH-not-found errors + ToolCallRepair fires per
|
||||
turn. 3+ signals flip the rest of the turn to `v4-pro` with a
|
||||
visible warning row. Counter resets at every turn start. No
|
||||
silent cost surprises.
|
||||
- **Model self-report escalation (`<<<NEEDS_PRO>>>`)** — system
|
||||
prompt teaches the model that when a task CLEARLY exceeds flash's
|
||||
capability (complex architecture, subtle correctness, genuine
|
||||
design trade-offs), emit the marker as the first line of its
|
||||
response. The loop aborts that call, retries this turn on pro,
|
||||
one shot. Guarded against infinite retry (pro never self-
|
||||
escalates) and streaming output is buffered so the marker never
|
||||
flickers on-screen before the retry fires.
|
||||
- **Turn-end auto-compaction** — every tool result over 3000 tokens
|
||||
gets shrunk to a cap at turn end. Biggest win for long sessions:
|
||||
a 12KB `read_file` output stops re-paying its cost on every
|
||||
future prompt. The proactive in-turn threshold also dropped from
|
||||
60% → 40% so the reactive 80% path rarely fires.
|
||||
- **Forced-summary + truncation-repair auxiliary calls hard-route to
|
||||
flash+effort=high** regardless of the main-turn tier. No reason to
|
||||
pay pro rates for "paraphrase these tool results into prose" or
|
||||
"close this truncated JSON."
|
||||
- **Subagent default flipped to `v4-flash` + `effort=high`**. Skill
|
||||
frontmatter `model:` / `effort:` remain the per-skill override.
|
||||
- **StatsPanel cost badges** — per-turn cost alongside session total.
|
||||
Colored thresholds: turn green under $0.05, yellow $0.05–0.20,
|
||||
red ≥$0.20; session same scale ×10.
|
||||
|
||||
### Added — UX
|
||||
|
||||
- **Plan body now flows into scrollback**, not inside the modal.
|
||||
`submit_plan` pushes a dedicated `role: "plan"` row into the
|
||||
Static log (rendered via the full markdown pipeline, never
|
||||
truncated); the PlanConfirm modal below shrinks to a tight
|
||||
approve/refine/cancel picker. Long plans are fully readable via
|
||||
terminal scrollback.
|
||||
- **Shared prompt fragments** — `TUI_FORMATTING_RULES` and
|
||||
`NEGATIVE_CLAIM_RULE` live once in `src/prompt-fragments.ts`,
|
||||
embedded into every system prompt (main code, default chat,
|
||||
subagent, built-in skills). Three near-identical copies
|
||||
collapsed; subagents gain the "don't assert absence without
|
||||
checking" guardrail they previously lacked.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`run_skill` accepts decorated names.** The Skills index wrote
|
||||
entries like `- 🧬 explore`, and models copied the whole thing
|
||||
verbatim into `run_skill({name:"🧬 explore"})`. The index now
|
||||
uses a trailing `[🧬 subagent]` tag after the name, and
|
||||
`run_skill` normalizes inputs by stripping bracketed tags +
|
||||
leading emoji before lookup. Handles `"🧬 explore"`,
|
||||
`"[🧬 subagent] explore"`, `"explore [🧬 subagent]"`, etc.
|
||||
- **`edit_file` result no longer shown twice.** The interceptor's
|
||||
`applyNow` was pushing an info row, and the loop's tool event
|
||||
re-displayed the same text as a proper tool row. Dropped the info
|
||||
row push; the tool row alone carries the content.
|
||||
- **`run_command` / `run_background` descriptions teach their shell
|
||||
constraints upfront.** Explicit list of rejected operators
|
||||
(`&&`, `||`, `|`, `;`, `>`, `<`, `2>&1`), the `cd` doesn't-persist
|
||||
rule, a warning against unbounded-output commands (`netstat -ano`,
|
||||
`find /`), and concrete alternatives (`npm --prefix`, `cargo -C`,
|
||||
`git -C`). Models stop burning turns rediscovering these via
|
||||
error replies.
|
||||
|
||||
### Refactored (no behavior change)
|
||||
|
||||
- **App.tsx split** from 2931 → ~1980 lines by extracting
|
||||
`LiveRows.tsx`, `edit-history.ts`, `useEditHistory.ts`,
|
||||
`useCompletionPickers.ts`, `useSessionInfo.ts`, and
|
||||
`useSubagent.ts`. Every hook under 310 lines.
|
||||
- **slash.ts split** from 1786 → 20-line barrel. Types,
|
||||
SLASH_COMMANDS data + parse helpers, shared utility helpers, a
|
||||
handler registry (`dispatch.ts`), and 10 per-topic handler files
|
||||
all under `src/cli/ui/slash/`. Adding a command now means editing
|
||||
one handler file + one registry line.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`docs/ARCHITECTURE.md` rewritten** for v0.6. The four pillars,
|
||||
current module layout (slash + handlers + hooks all reflected),
|
||||
design-evolution timeline replacing the stale roadmap,
|
||||
non-goals updated to call out "automatic cost escalation without
|
||||
user-visible announcement" as explicitly rejected.
|
||||
|
||||
## [0.5.24] — 2026-04-24
|
||||
|
||||
**Headline:** `reasonix code` gets a proper review gate, background
|
||||
process support, and aggressive context hygiene so long coding
|
||||
sessions stop bleeding money.
|
||||
|
||||
### Added
|
||||
|
||||
- **Edit-gate modes (review / auto)** — `edit_file` and `write_file`
|
||||
tool calls now route through a user gate. `review` (default) pops
|
||||
an `EditConfirm` modal with a scrollable diff + `y/n/a/A/Esc`
|
||||
keys; `auto` applies immediately and arms a 5-second undo banner.
|
||||
`Shift+Tab` cycles, `/mode` sets explicitly. Persisted to
|
||||
`~/.reasonix/config.json`.
|
||||
- **Session edit history** — every applied batch lands in an
|
||||
in-memory ring. `/history` lists them, `/show [id] [path]` dumps
|
||||
a stored diff (per-file when path given), `/undo [id] [path]`
|
||||
rolls back at any granularity (latest batch, specific batch,
|
||||
single file inside a batch). `u` keybind reaches back past the
|
||||
5-second banner as long as history has a non-undone entry.
|
||||
- **Background processes** — new `run_background` / `job_output` /
|
||||
`stop_job` / `list_jobs` tools for dev servers and watchers. Spawn
|
||||
returns after a ready-signal match (`listening on`, `Local:`,
|
||||
`compiled successfully`, …) or `waitSec` seconds. `/jobs` /
|
||||
`/kill <id>` / `/logs <id>` surface them to the user. Cleanup on
|
||||
SIGINT / SIGTERM / exit kills every child.
|
||||
- **Per-edit review modal (`src/cli/ui/EditConfirm.tsx`)** — diff
|
||||
viewport sized to terminal rows; `↑↓/j/k/Space/PgUp/PgDn/g/G`
|
||||
scroll a big diff in place. `a` applies rest of turn, `A` flips
|
||||
to AUTO for the session.
|
||||
- **Bottom mode status bar** — always-visible line above the prompt
|
||||
shows mode / pending count / Shift+Tab hint / running-jobs tag;
|
||||
flashes on mode change.
|
||||
- **Onboarding tip** — first `reasonix code` launch after upgrade
|
||||
posts the edit-gate keybindings once; suppressed after via the
|
||||
`editModeHintShown` flag.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`read_file`** — adds `range:"A-B"` param (1-indexed, inclusive).
|
||||
Files longer than 200 lines with no scope return an auto-preview
|
||||
(head 80 + tail 40 + "N lines omitted" marker) instead of dumping
|
||||
everything. One `read_file` used to burn 6.5K tokens on a fat
|
||||
file; scoped reads cut that 3-5×.
|
||||
- **`directory_tree`** — default `maxDepth` 4 → 2; skips
|
||||
`node_modules`, `.git`, `dist`, `build`, `out`, `.next`, `.nuxt`,
|
||||
`target`, `.venv`, `venv`, `__pycache__`, `.pytest_cache`,
|
||||
`.mypy_cache`, `.cache`, `coverage` unless `include_deps:true`;
|
||||
collapses any directory past 50 children with a nudge toward
|
||||
`list_directory`.
|
||||
- **Auto-compact tool-call args** — after every `tool` response, the
|
||||
loop shrinks that call's `arguments` JSON if it exceeds 800
|
||||
tokens. Paths and short fields stay verbatim; long SEARCH /
|
||||
REPLACE / content strings get replaced with a `[…shrunk: N chars,
|
||||
M lines — tool already responded, see result]` marker. Cuts
|
||||
stale-args drag across every subsequent turn.
|
||||
- **`/compact`** — now covers both tool results (existing) and
|
||||
tool-call args (new) in one pass.
|
||||
- **`reasoningEffort` persistence** — `/effort high` now writes the
|
||||
choice to `~/.reasonix/config.json` and the loop picks it up at
|
||||
launch. Earlier versions silently reverted to `max` every relaunch.
|
||||
- **Prompt scope discipline** — code-mode prompt tells the model to
|
||||
stop after "run / start / launch" tasks instead of proactively
|
||||
refactoring, running tsc, or chasing unused imports.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`run_background` confirmation path** — TUI now pops the shell
|
||||
confirm modal for `run_background` (not just `run_command`). A
|
||||
`kind` field on `pendingShell` routes approval to
|
||||
`JobRegistry.start()` so approving doesn't synchronously block on
|
||||
a dev server that never exits.
|
||||
- **`/kill` actually kills the tree** — Windows `taskkill /T /F`,
|
||||
POSIX `process.kill(-pid, …)` on a detached child. Earlier
|
||||
`SIGTERM` only killed the `npm.cmd` wrapper; `node → vite →
|
||||
esbuild` survived. `/kill` also posts a late "job N exit M" row
|
||||
when the stop resolves, so the user doesn't have to poll `/jobs`.
|
||||
|
||||
## [0.4.24] — 2026-04-22
|
||||
|
||||
**Headline:** `reasonix stats` is now a cross-session cost dashboard.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Cache-first agent loop for DeepSeek V3 & R1 — Ink TUI, MCP first-class, no LangChain.</em>
|
||||
<em>Cache-first agent loop for DeepSeek V4 (flash + pro) — Ink TUI, MCP first-class, no LangChain.</em>
|
||||
</p>
|
||||
|
||||
# Reasonix
|
||||
@@ -133,6 +133,34 @@ shell call will execute. Use for high-stakes changes you want to
|
||||
audit before the model touches disk. `/plan off` or picker
|
||||
Approve/Cancel exits.
|
||||
|
||||
### Prompt prefixes — `!cmd` and `@path`
|
||||
|
||||
Two inline shortcuts that don't need a slash:
|
||||
|
||||
**`!<cmd>` — run a shell command in the sandbox and feed it to the
|
||||
model.** Typed at the prompt, like bash. Output lands in the visible
|
||||
log AND in the session so the model's next turn reasons about it:
|
||||
|
||||
```
|
||||
reasonix code › !git status --short
|
||||
▸ M src/users.ts
|
||||
▸ M src/users.test.ts
|
||||
|
||||
reasonix code › 把这两个文件的改动说明一下
|
||||
assistant
|
||||
▸ tool<read_file> → src/users.ts, src/users.test.ts
|
||||
▸ …
|
||||
```
|
||||
|
||||
No allowlist gate — user-typed shell = explicit consent. 60s timeout,
|
||||
32k char cap, survives session resume since 0.5.14.
|
||||
|
||||
**`@path/to/file` — inline a file under "Referenced files."** Start
|
||||
typing `@` and a picker appears (↑/↓ navigate, Tab/Enter to insert).
|
||||
Good for "what does @src/users.ts do?" without making the model
|
||||
`read_file` it first. Sandboxed: relative paths only, no `..` escape,
|
||||
64KB per-file cap. Recent files rank higher.
|
||||
|
||||
### `/commit` — stage + commit in one step
|
||||
|
||||
```
|
||||
@@ -145,12 +173,14 @@ reasonix code › /commit "fix: findByEmail case-insensitive"
|
||||
|
||||
- `/tool 1` — dump the last tool call's full output (when the 400-char
|
||||
inline clip isn't enough).
|
||||
- `/think` — see the model's full R1 reasoning for the last turn
|
||||
(reasoner preset only).
|
||||
- `/think` — see the model's full reasoning for the last turn
|
||||
(thinking-mode models: v4-flash / v4-pro / reasoner alias).
|
||||
- `/undo` — roll back the last applied edit batch.
|
||||
- `/new` — start fresh in the same directory without losing the
|
||||
session file.
|
||||
- `npx reasonix code --preset max` — R1 + 3-way self-consistency
|
||||
- `/effort high` — step down from the default `max` agent-class
|
||||
reasoning_effort for cheaper/faster turns on simple tasks.
|
||||
- `npx reasonix code --preset max` — v4-pro + 3-way self-consistency
|
||||
branching for gnarly refactors.
|
||||
- `npx reasonix code src/` — narrower sandbox (only `src/` is
|
||||
writable).
|
||||
@@ -182,7 +212,9 @@ in the file. No prompts, no completions, no tool arguments.
|
||||
### Staying current
|
||||
|
||||
The panel header shows the running version next to `Reasonix` (e.g.
|
||||
`Reasonix v0.4.22 · model …`). A quiet 24-hour background check against
|
||||
`Reasonix v0.5.21 · deepseek-v4-pro · harvest · max …`, the trailing
|
||||
`max` is the reasoning-effort badge — `/effort high` to step down).
|
||||
A quiet 24-hour background check against
|
||||
the npm registry surfaces a yellow `update: X.Y.Z` on the right side
|
||||
of the same row when a newer version has been published. No blocking,
|
||||
no nagging — the check runs once per day max and is silent on failure
|
||||
@@ -403,18 +435,25 @@ rendering, retries.
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `/preset <fast\|smart\|max>` | one-tap bundle (model + harvest + branch) |
|
||||
| `/model <id>` | switch DeepSeek model (`deepseek-chat`, `deepseek-reasoner`) |
|
||||
| `/model <id>` | switch DeepSeek model (`deepseek-v4-flash`, `deepseek-v4-pro`, plus `deepseek-chat` / `deepseek-reasoner` compat aliases) |
|
||||
| `/models` | list live models from DeepSeek `/models` endpoint |
|
||||
| `/harvest [on\|off]` | toggle R1 plan-state extraction |
|
||||
| `/branch <N\|off>` | run N parallel samples per turn, pick best (N ≥ 2) |
|
||||
| `/think` | dump the last turn's full R1 reasoning |
|
||||
| `/effort <high\|max>` | reasoning_effort cap — `max` is the agent default, `high` is cheaper/faster |
|
||||
| `/think` | dump the last turn's full thinking-mode reasoning |
|
||||
|
||||
**Context & tools**
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `/mcp` | list attached MCP servers and their tools |
|
||||
| `/mcp` | list attached MCP servers and their tools / resources / prompts |
|
||||
| `/resource [uri]` | browse + read MCP resources (no arg → list URIs; `<uri>` → fetch) |
|
||||
| `/prompt [name]` | browse + fetch MCP prompts |
|
||||
| `/tool [N]` | dump the Nth tool call's full output (1 = latest) |
|
||||
| `/compact [cap]` | shrink oversized tool results in the log |
|
||||
| `/compact [tokens]` | shrink oversized tool results in the log (default 4000 tokens/result) |
|
||||
| `/context` | break down where context tokens are going (system / tools / log) |
|
||||
| `/stats` | cross-session cost dashboard (today / week / month / all-time) |
|
||||
| `/keys` | keyboard shortcuts + prompt prefixes (`!` / `@` / `/`) cheatsheet |
|
||||
|
||||
**Memory & skills**
|
||||
|
||||
@@ -468,8 +507,8 @@ rendering, retries.
|
||||
- Malformed `assistant.tool_calls` / `tool` pairing is validated on
|
||||
every outgoing API call so a corrupted session can't keep 400ing.
|
||||
- Context gauge turns yellow at 50%, red at 80% with a `/compact`
|
||||
nudge. Approaching the 131k window triggers an automatic
|
||||
compaction attempt before falling back to a forced summary.
|
||||
nudge. Approaching the 1M-token window (V4 flash + pro) triggers an
|
||||
automatic compaction attempt before falling back to a forced summary.
|
||||
- The `reasonix code` sandbox refuses any path that resolves outside
|
||||
the launch directory, including symlink escape and `..` traversal.
|
||||
|
||||
@@ -728,7 +767,7 @@ cd reasonix
|
||||
npm install
|
||||
npm run dev code # run CLI from source via tsx
|
||||
npm run build # tsup to dist/
|
||||
npm test # vitest (648 tests)
|
||||
npm test # vitest (1007 tests)
|
||||
npm run lint # biome
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
+166
-62
@@ -6,7 +6,11 @@ Reasonix is **opinionated, not general**. Every abstraction is justified by a
|
||||
DeepSeek-specific behavior or economic property. If it's generic, we don't
|
||||
ship it.
|
||||
|
||||
## The three pillars
|
||||
The product north star: **coding agent that stays cheap enough to leave on**.
|
||||
A tool that quietly burns $200/month on a background project is one nobody
|
||||
uses. Every subsystem below is answerable to that goal.
|
||||
|
||||
## The four pillars
|
||||
|
||||
### Pillar 1 — Cache-First Loop
|
||||
|
||||
@@ -37,49 +41,29 @@ timestamps each turn — cache hit rate in practice: <20%.
|
||||
into the log.
|
||||
|
||||
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
|
||||
aggregated per-session. This is the user-visible proof of Pillar 1's value.
|
||||
aggregated per-session. Visible in the TUI's top-bar cache cell.
|
||||
|
||||
### Pillar 2 — R1 Thought Harvesting *(v0.0.3, opt-in)*
|
||||
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
|
||||
|
||||
**Problem.** R1 emits extensive `reasoning_content`. DeepSeek's own docs
|
||||
recommend *not* feeding it back to the next turn. Most frameworks display it
|
||||
to the user and discard. The planning signal inside is lost.
|
||||
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
|
||||
own docs recommend *not* feeding it back to the next turn. Most frameworks
|
||||
display it to the user and discard. The planning signal inside is lost.
|
||||
|
||||
**Solution.** A two-stage process:
|
||||
|
||||
```
|
||||
R1 output → Harvester (V3, cheap) → TypedPlanState
|
||||
├─ subgoals: string[]
|
||||
├─ hypotheses: string[]
|
||||
├─ uncertainties: string[]
|
||||
└─ rejectedPaths: string[]
|
||||
R1 output → Harvester (v4-flash, thinking off) → TypedPlanState
|
||||
├─ subgoals: string[]
|
||||
├─ hypotheses: string[]
|
||||
├─ uncertainties: string[]
|
||||
└─ rejectedPaths: string[]
|
||||
```
|
||||
|
||||
The harvester is a cheap V3 call with a strict JSON schema. Output is
|
||||
validated at runtime. The typed state is queryable by the orchestrator — e.g.
|
||||
"if `uncertainties.length > 2`, trigger branch sampling."
|
||||
The harvester is a cheap flash call with a strict JSON schema. Output is
|
||||
validated at runtime. **Not enabled in any preset** (see Pillar 4) — the
|
||||
additional round-trip rarely pays back, so it's `/harvest on` opt-in.
|
||||
|
||||
### Branch-and-Select *(v0.0.5, opt-in, builds on Pillar 2)*
|
||||
|
||||
**Why now.** DeepSeek is cheap enough that running N=3 R1 samples is still
|
||||
cheaper than a single Claude call. What was research luxury (self-consistency
|
||||
sampling) becomes a practical default.
|
||||
|
||||
**How.** When `--branch N` (or `{ branch: N }` in code) is passed:
|
||||
|
||||
1. The turn is forced non-streaming (N samples must complete before we can
|
||||
compare).
|
||||
2. `runBranches` fires N `client.chat` calls in parallel, spreading
|
||||
temperatures across `[0, 1]` to diversify reasoning paths.
|
||||
3. Each sample's `reasoning_content` is piped through Pillar 2's harvest.
|
||||
4. The default selector picks the sample with the fewest `uncertainties`;
|
||||
ties are broken by shorter answer length (Occam).
|
||||
5. The loop emits a `branch_summary` alongside the winning `assistant_final`.
|
||||
|
||||
**Cost.** 3× R1 on a hard math problem ≈ \$0.02 — still dominates Claude on
|
||||
cost/quality for agentic work.
|
||||
|
||||
### Pillar 3 — Tool-Call Repair *(v0.0.1 ships complete)*
|
||||
### Pillar 3 — Tool-Call Repair
|
||||
|
||||
**Problem.** Empirical DeepSeek failure modes:
|
||||
- Tool-call JSON emitted inside `<think>`, missing from the final message.
|
||||
@@ -92,7 +76,6 @@ cost/quality for agentic work.
|
||||
1. **`flatten`** — schemas with >10 leaf params or depth >2 are auto-detected
|
||||
on `ToolRegistry.register()` and presented to the model in dot-notation
|
||||
form. `dispatch()` re-nests the args before calling the user's `fn`.
|
||||
Opt out with `new ToolRegistry({ autoFlatten: false })`.
|
||||
2. **`scavenge`** — regex + JSON parser sweeps `reasoning_content` for any tool
|
||||
call the model forgot to emit in `tool_calls`.
|
||||
3. **`truncation`** — detect unbalanced JSON and repair by closing braces or
|
||||
@@ -100,48 +83,169 @@ cost/quality for agentic work.
|
||||
4. **`storm`** — identical `(tool, args)` tuple within a sliding window →
|
||||
suppress the call, inject a reflection turn.
|
||||
|
||||
### Pillar 4 — Cost Control *(v0.6)*
|
||||
|
||||
**Problem.** Coding agents that default to the frontier model (v4-pro, ~12×
|
||||
flash cost) and accumulate full tool results in context are $150-$250/month
|
||||
for active users. Most turns don't need frontier reasoning; most sessions
|
||||
re-pay for tool results that were only useful once.
|
||||
|
||||
**Solution.** Four complementary mechanisms, none of which require manual
|
||||
tuning in the common case:
|
||||
|
||||
#### 4.1 Tiered defaults (flash-first)
|
||||
|
||||
The three presets now trade only **model tier** and **reasoning effort**:
|
||||
|
||||
| Preset | Model | Effort | Harvest | Branch | Cost |
|
||||
|---|---|---|---|---|---:|
|
||||
| `fast` | `v4-flash` | `high` | off | 1 | 1× |
|
||||
| `smart` (default) | `v4-flash` | `max` | off | 1 | ~1.5× |
|
||||
| `max` | `v4-pro` | `max` | off | 1 | ~12× |
|
||||
|
||||
**Branching and harvest are never in a preset.** Both multiply cost and
|
||||
rarely pay back; `/branch N` and `/harvest on` are pure opt-ins. A user can
|
||||
run for weeks without ever touching them.
|
||||
|
||||
All auxiliary calls — `forceSummaryAfterIterLimit`, subagent spawns,
|
||||
truncation repair retries — hard-code `v4-flash + effort=high` regardless
|
||||
of the user's preset. There's no reason to pay pro rates for "paraphrase
|
||||
these tool results into prose" or for an `explore` subagent's grep chain.
|
||||
|
||||
#### 4.2 Turn-end auto-compaction
|
||||
|
||||
Every tool result in the log exceeding `TURN_END_RESULT_CAP_TOKENS` (3000)
|
||||
is shrunk to that cap when a turn ends. The model had the full text for
|
||||
the turn that read it; subsequent turns see a compact summary and can
|
||||
re-read if needed. One extra `read_file` call is vastly cheaper than
|
||||
dragging 12KB through every future prompt.
|
||||
|
||||
A proactive 40% context-ratio threshold runs the same shrink pre-emptively
|
||||
inside long multi-iter turns before the 80% emergency threshold fires.
|
||||
|
||||
#### 4.3 `/pro` single-turn arming
|
||||
|
||||
Users who predict a hard task type `/pro`; the **next** turn runs on
|
||||
`v4-pro`, then auto-disarms. No preset churn, no forgotten revert. Armed
|
||||
state is visible as a yellow `⇧ pro armed` pill in the header.
|
||||
|
||||
#### 4.4 Failure-signal auto-escalation
|
||||
|
||||
The loop counts visible "flash is struggling" events per turn:
|
||||
- `edit_file` / `write_file` SEARCH-not-found errors
|
||||
- ToolCallRepair fires (scavenge / truncation-fix / storm-break)
|
||||
|
||||
Once the count hits `FAILURE_ESCALATION_THRESHOLD` (3), the **remainder of
|
||||
the current turn** runs on `v4-pro`. Announced via a yellow warning row —
|
||||
no silent cost surprises. Counter + escalation flag reset at every turn
|
||||
start.
|
||||
|
||||
Header shows a red `⇧ pro escalated` pill while the turn is on pro.
|
||||
|
||||
#### Cost transparency
|
||||
|
||||
Per-turn and session cost are colored in the StatsPanel:
|
||||
- `turn $0.003` — green <$0.05, yellow $0.05–0.20, red ≥$0.20
|
||||
- `session $0.12` — same scale ×10
|
||||
|
||||
## Module layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── client.ts # httpx-equivalent DeepSeek client (fetch + SSE)
|
||||
├── loop.ts # Pillar 1: Cache-First Loop (async iterator)
|
||||
├── harvest.ts # Pillar 2 stub (v0.0.1 surface only)
|
||||
├── repair/
|
||||
│ ├── index.ts # Pillar 3 pipeline
|
||||
├── client.ts # DeepSeek client (fetch + SSE)
|
||||
├── loop.ts # Pillar 1 + 4 — CacheFirstLoop
|
||||
├── harvest.ts # Pillar 2 — flash-backed plan-state extraction
|
||||
├── repair/ # Pillar 3 pipeline
|
||||
│ ├── index.ts
|
||||
│ ├── scavenge.ts
|
||||
│ ├── flatten.ts
|
||||
│ ├── truncation.ts
|
||||
│ └── storm.ts
|
||||
├── tools.ts # Tool registry + dispatch
|
||||
├── memory.ts # Prefix / Log / Scratch primitives
|
||||
├── telemetry.ts # Cost & cache-hit accounting
|
||||
├── types.ts # Shared type definitions
|
||||
├── index.ts # Library barrel export
|
||||
├── prompt-fragments.ts # TUI_FORMATTING_RULES, NEGATIVE_CLAIM_RULE —
|
||||
│ # reused by main + subagent + skill prompts
|
||||
├── code/prompt.ts # reasonix code main system prompt
|
||||
├── tools/ # Tool implementations
|
||||
│ ├── filesystem.ts # read / list / search / edit / write
|
||||
│ ├── shell.ts # run_command + run_background (JobRegistry)
|
||||
│ ├── jobs.ts # background-process registry
|
||||
│ ├── memory.ts # remember / forget / list user memories
|
||||
│ ├── skills.ts # list + invoke SKILL.md playbooks
|
||||
│ ├── subagent.ts # spawn_subagent — flash+high by default
|
||||
│ ├── plan.ts # submit_plan (review gate)
|
||||
│ └── web.ts # web_search, web_fetch
|
||||
├── mcp/ # MCP client + bridge (stdio + SSE)
|
||||
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
|
||||
├── project-memory.ts # REASONIX.md loader
|
||||
├── user-memory.ts # ~/.reasonix/memory/ store (project + global)
|
||||
├── skills.ts # built-in explore + research skills
|
||||
├── session.ts # JSONL session persistence
|
||||
├── telemetry.ts # cost + cache-hit accounting + SessionSummary
|
||||
├── tokenizer.ts # DeepSeek V3 tokenizer (ported)
|
||||
├── usage.ts # ~/.reasonix/usage.jsonl roll-up
|
||||
├── types.ts # ChatMessage, ToolCall, ToolSpec
|
||||
├── index.ts # library barrel
|
||||
└── cli/
|
||||
├── index.ts # commander entry
|
||||
├── commands/ # chat, run, stats, version
|
||||
└── ui/ # Ink React components (App, StatsPanel, EventLog, PromptInput)
|
||||
├── index.ts # commander entry
|
||||
├── resolve.ts # config + CLI flag precedence
|
||||
├── commands/ # chat, code, run, stats, sessions, ...
|
||||
└── ui/
|
||||
├── App.tsx # root Ink component (~1984 LOC, was 2931)
|
||||
├── LiveRows.tsx # spinner rows (OngoingTool / Status / ...)
|
||||
├── EventLog.tsx # Historical row rendering
|
||||
├── StatsPanel.tsx # top bar + cost badges
|
||||
├── PromptInput.tsx # cursor-aware multi-line input
|
||||
├── PlanConfirm.tsx # submit_plan review modal
|
||||
├── ShellConfirm.tsx # run_command approval modal
|
||||
├── EditConfirm.tsx # per-edit review modal
|
||||
├── markdown.tsx # Ink-native markdown renderer
|
||||
├── edit-history.ts # EditHistoryEntry + formatters
|
||||
├── useEditHistory.ts # /undo, /history, /show state machine
|
||||
├── useCompletionPickers.ts # slash, @, slash-arg pickers
|
||||
├── useSessionInfo.ts # balance + models + updates fetch
|
||||
├── useSubagent.ts # subagent sink wiring
|
||||
└── slash/ # /-command implementation
|
||||
├── types.ts # SlashContext, SlashResult, ...
|
||||
├── commands.ts # SLASH_COMMANDS data + parse + suggest
|
||||
├── helpers.ts # git, memory, token formatters
|
||||
├── dispatch.ts # registry + handleSlash lookup
|
||||
└── handlers/ # per-topic: basic, mcp, memory,
|
||||
# skill, admin, observability, edits,
|
||||
# jobs, sessions, model (/pro lives here)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
Files kept small by design: the largest module under `cli/ui/` is 2K
|
||||
lines (App.tsx), every handler under `slash/handlers/` is ≤200 lines,
|
||||
every hook under `cli/ui/` is ≤310 lines. Adding a new slash command
|
||||
means editing one handler file and one registry line.
|
||||
|
||||
- **v0.0.1** — Pillar 1 end-to-end, Pillar 3 complete, Ink TUI, τ-bench scaffold.
|
||||
- **v0.0.2** — First-run key prompt, saved to `~/.reasonix/config.json`.
|
||||
- **v0.0.3** — Pillar 2 MVP (opt-in harvest), retry layer, TextInput fix.
|
||||
- **v0.0.4** — Schema flatten auto-applied in ToolRegistry (closes Pillar 3).
|
||||
- **v0.0.5** — Self-consistency branching (`--branch N`) driven by Pillar 2
|
||||
plan-state uncertainty count.
|
||||
- **v0.0.6** — Named session persistence (`--session <name>`), JSONL log
|
||||
stored under `~/.reasonix/sessions/`, restored into the append-only log
|
||||
on resume.
|
||||
## Design evolution
|
||||
|
||||
- **v0.0.x** — Pillar 1 end-to-end, Pillar 3 complete, Ink TUI scaffold.
|
||||
- **v0.1** — τ-bench numbers published, streaming polish, transcript replay.
|
||||
- **v0.2** — Self-consistency / branch-budget sampling driven by plan state.
|
||||
- **v0.3** — MCP client, session persistence.
|
||||
- **v0.3** — MCP client (stdio + SSE), session persistence.
|
||||
- **v0.4.x** — `reasonix code` with SEARCH/REPLACE edits, review/auto
|
||||
gate, background jobs, hooks.
|
||||
- **v0.5.x** — V4 model support, skills, memory, subagents, actionable
|
||||
error messages.
|
||||
- **v0.6** *(current)* —
|
||||
- **Pillar 4 cost control** (flash-first defaults, auto-compaction,
|
||||
`/pro` one-shot, failure-triggered escalation, cost badges).
|
||||
- `deepseek-chat` / `deepseek-reasoner` scheduled for deprecation —
|
||||
all user-facing surfaces updated to `v4-flash` / `v4-pro`.
|
||||
- `branch` + `harvest` removed from every preset; manual opt-in only.
|
||||
- Shared prompt fragments (`TUI_FORMATTING_RULES`, `NEGATIVE_CLAIM_RULE`).
|
||||
- UI refactor: App.tsx split into 6 hooks/components, slash.ts split
|
||||
into 13 per-topic modules.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- Multi-agent orchestration.
|
||||
- Multi-agent orchestration as a first-class concept (subagents are a
|
||||
cost-reduction mechanism, not a coordination primitive).
|
||||
- RAG / vector retrieval.
|
||||
- Support for non-DeepSeek backends.
|
||||
- Support for non-DeepSeek backends (an OpenAI-compatible shim would
|
||||
work today via `--model` override, but is not tested).
|
||||
- Web UI / SaaS.
|
||||
- Automatic cost escalation without user-visible announcement. Every
|
||||
pro-tier model call is surfaced; silent escalation was considered
|
||||
and rejected.
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.5.14",
|
||||
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
|
||||
"version": "0.6.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"reasonix": "dist/cli/index.js"
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface ChatOptions {
|
||||
* Enable SEARCH/REPLACE edit-block processing after each assistant turn.
|
||||
* Set by `reasonix code`; plain `reasonix chat` leaves this off.
|
||||
*/
|
||||
codeMode?: { rootDir: string };
|
||||
codeMode?: { rootDir: string; jobs?: import("../../tools/jobs.js").JobRegistry };
|
||||
/** Skip the session picker — assume "Resume" (backwards-compatible auto-continue). */
|
||||
forceResume?: boolean;
|
||||
/** Skip the session picker — assume "New" (wipe the session file and start fresh). */
|
||||
|
||||
@@ -23,6 +23,7 @@ import { loadProjectShellAllowed } from "../../config.js";
|
||||
import { sanitizeName } from "../../session.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { registerFilesystemTools } from "../../tools/filesystem.js";
|
||||
import { JobRegistry } from "../../tools/jobs.js";
|
||||
import { registerMemoryTools } from "../../tools/memory.js";
|
||||
import { registerPlanTool } from "../../tools/plan.js";
|
||||
import { registerShellTools } from "../../tools/shell.js";
|
||||
@@ -66,6 +67,11 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// triggered R1's DSML hallucinations all through 0.4.x.
|
||||
const tools = new ToolRegistry();
|
||||
registerFilesystemTools(tools, { rootDir });
|
||||
// Background-process registry shared between the shell tools and the
|
||||
// TUI's /jobs + /kill slashes + exit cleanup. One per `reasonix code`
|
||||
// run — orphan prevention on SIGINT / process exit kills everything
|
||||
// it owns, so dev servers don't outlive the Reasonix process.
|
||||
const jobs = new JobRegistry();
|
||||
registerShellTools(tools, {
|
||||
rootDir,
|
||||
// Per-project "always allow" list persisted from prior ShellConfirm
|
||||
@@ -74,6 +80,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// via ShellConfirm mid-session takes effect on the next shell call
|
||||
// instead of waiting for `/new` or a relaunch.
|
||||
extraAllowed: () => loadProjectShellAllowed(rootDir),
|
||||
jobs,
|
||||
});
|
||||
// `submit_plan` is always in the spec list so the prefix cache stays
|
||||
// stable across plan-mode toggles (Pillar 1). The tool itself is a
|
||||
@@ -94,14 +101,26 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" · ${tools.size} native tool(s)\n`,
|
||||
);
|
||||
|
||||
// Belt-and-suspenders cleanup: even though spawn(detached:false)
|
||||
// should tie child processes to the parent's lifetime, Windows cmd.exe
|
||||
// wrappers occasionally leak. Kill everything on both clean and
|
||||
// signal-driven exits. 'exit' fires too late for async I/O but is our
|
||||
// only guaranteed last word; the signal handlers do the heavy lifting.
|
||||
const sigShutdown = () => {
|
||||
void jobs.shutdown();
|
||||
};
|
||||
process.once("SIGINT", sigShutdown);
|
||||
process.once("SIGTERM", sigShutdown);
|
||||
process.once("exit", sigShutdown);
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-reasoner",
|
||||
model: opts.model ?? "deepseek-v4-flash",
|
||||
harvest: opts.harvest ?? false,
|
||||
system: codeSystemPrompt(rootDir),
|
||||
transcript: opts.transcript,
|
||||
session,
|
||||
seedTools: tools,
|
||||
codeMode: { rootDir },
|
||||
codeMode: { rootDir, jobs },
|
||||
forceResume: opts.forceResume,
|
||||
forceNew: opts.forceNew,
|
||||
});
|
||||
|
||||
+10
-7
@@ -1,6 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { readConfig } from "../config.js";
|
||||
import { VERSION } from "../index.js";
|
||||
import { ESCALATION_CONTRACT } from "../prompt-fragments.js";
|
||||
import { applyMemoryStack } from "../user-memory.js";
|
||||
import { chatCommand } from "./commands/chat.js";
|
||||
import { codeCommand } from "./commands/code.js";
|
||||
@@ -28,7 +29,9 @@ Every factual claim about a codebase must be backed by evidence. Reasonix VALIDA
|
||||
|
||||
**Negative claims** ("X is missing", "Y isn't implemented", "lacks Z") are the #1 hallucination shape. STOP before writing them. If you have a search tool, call it first; if the search returns nothing, cite the search itself as evidence (\`No matches for "foo" in src/\`). If you have no tool, qualify hard: "I haven't verified — this is a guess."
|
||||
|
||||
Asserting absence without checking is how evaluative answers go wrong. Treat the urge to write "missing" as a red flag in your own reasoning.`;
|
||||
Asserting absence without checking is how evaluative answers go wrong. Treat the urge to write "missing" as a red flag in your own reasoning.
|
||||
|
||||
${ESCALATION_CONTRACT}`;
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
@@ -68,16 +71,16 @@ program
|
||||
program
|
||||
.command("code [dir]")
|
||||
.description(
|
||||
"Code-editing chat — filesystem tools rooted at <dir> (default: cwd), coding system prompt, deepseek-reasoner. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
|
||||
"Code-editing chat — filesystem tools rooted at <dir> (default: cwd), coding system prompt, v4-flash. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk. Use /preset max or /pro to escalate to v4-pro on hard tasks.",
|
||||
)
|
||||
.option("-m, --model <id>", "Override default reasoner model")
|
||||
.option("-m, --model <id>", "Override default model (v4-flash)")
|
||||
.option("--no-session", "Disable session persistence for this run")
|
||||
.option("-r, --resume", "Skip the session picker — always continue prior messages")
|
||||
.option("-n, --new", "Skip the session picker — always wipe prior messages and start fresh")
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.option(
|
||||
"--harvest",
|
||||
"Extract typed plan state from R1 reasoning (Pillar 2). Adds ~10-15% cost per turn. Off by default in code mode.",
|
||||
"Opt-in Pillar-2 plan-state extraction. Adds one flash call per turn; off by default (no preset enables it).",
|
||||
)
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
await codeCommand({
|
||||
@@ -99,15 +102,15 @@ program
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.option(
|
||||
"--preset <name>",
|
||||
"Bundle of model + harvest + branch. One of: fast, smart, max. Overrides config.preset.",
|
||||
"Model + effort bundle. One of: fast (flash·high), smart (flash·max, default), max (pro·max). Overrides config.preset.",
|
||||
)
|
||||
.option(
|
||||
"--harvest",
|
||||
"Extract typed plan state from R1 reasoning (Pillar 2). Overrides preset's harvest setting.",
|
||||
"Opt-in Pillar-2 plan-state extraction. Off by default — no preset enables it.",
|
||||
)
|
||||
.option(
|
||||
"--branch <n>",
|
||||
"Self-consistency: run N parallel samples per turn and pick the most confident (disables streaming; enables harvest)",
|
||||
"Self-consistency: run N parallel samples per turn (N× cost). Manual only — never auto-enabled.",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
)
|
||||
.option("--session <name>", "Use a named session (default: from config, usually 'default').")
|
||||
|
||||
+5
-3
@@ -6,7 +6,7 @@
|
||||
* 1. Explicit per-setting CLI flag (`--model`, `--harvest`, `--branch`, `--mcp`)
|
||||
* 2. Explicit `--preset <name>` CLI flag
|
||||
* 3. `config.preset` from `~/.reasonix/config.json` (set by `reasonix setup`)
|
||||
* 4. Hardcoded "fast" preset defaults
|
||||
* 4. Hardcoded "smart" preset defaults (flash + effort=max)
|
||||
*
|
||||
* Keeping this logic in one place — rather than duplicating across
|
||||
* `chat` and `run` — means the precedence rule only lives in one unit
|
||||
@@ -18,6 +18,7 @@ import { PRESETS } from "./ui/presets.js";
|
||||
|
||||
export interface ResolvedDefaults {
|
||||
model: string;
|
||||
reasoningEffort: "high" | "max";
|
||||
harvest: boolean;
|
||||
branch: number | undefined;
|
||||
mcp: string[];
|
||||
@@ -44,6 +45,7 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
const presetSettings = PRESETS[preset];
|
||||
|
||||
const model = flags.model ?? presetSettings.model;
|
||||
const reasoningEffort = presetSettings.reasoningEffort;
|
||||
const harvest = flags.harvest === true ? true : presetSettings.harvest;
|
||||
const branchFromFlag = normalizeBranch(flags.branch);
|
||||
const branch = branchFromFlag ?? (presetSettings.branch > 1 ? presetSettings.branch : undefined);
|
||||
@@ -55,7 +57,7 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
|
||||
const session = resolveSession(flags.session, cfg.session);
|
||||
|
||||
return { model, harvest, branch, mcp, session };
|
||||
return { model, reasoningEffort, harvest, branch, mcp, session };
|
||||
}
|
||||
|
||||
function pickPreset(
|
||||
@@ -64,7 +66,7 @@ function pickPreset(
|
||||
): PresetName {
|
||||
if (flagPreset && isPresetName(flagPreset)) return flagPreset;
|
||||
if (configPreset) return configPreset;
|
||||
return "fast";
|
||||
return "smart";
|
||||
}
|
||||
|
||||
function isPresetName(s: string): s is PresetName {
|
||||
|
||||
+639
-654
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,220 @@
|
||||
import { Box, Text, useInput, useStdout } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { formatEditBlockDiff } from "../../code/diff-preview.js";
|
||||
import type { EditBlock } from "../../code/edit-blocks.js";
|
||||
|
||||
/**
|
||||
* Choice surfaced to the interceptor when the user resolves the modal:
|
||||
* - `apply` → apply this edit, keep prompting on next.
|
||||
* - `reject` → tell the model the user refused; do NOT
|
||||
* retry the same edit.
|
||||
* - `apply-rest-of-turn` → apply this edit AND every remaining edit
|
||||
* this turn without further prompts. Resets
|
||||
* at the next user turn.
|
||||
* - `flip-to-auto` → apply this edit + persistently switch the
|
||||
* session to AUTO mode (survives restart).
|
||||
*/
|
||||
export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto";
|
||||
|
||||
export interface EditConfirmProps {
|
||||
block: EditBlock;
|
||||
onChoose: (choice: EditReviewChoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overhead reserved for non-diff UI inside the modal: border (2),
|
||||
* title (1), separator (1), file-header (1), scroll-indicator rows
|
||||
* (up to 2), footer + margin (3), plus breathing room for the host
|
||||
* TUI's chrome above (stats panel ≈ 4, historical live rows, prompt
|
||||
* input ≈ 2). 18 rows absorbed before the diff gets any budget.
|
||||
*/
|
||||
const MODAL_OVERHEAD_ROWS = 18;
|
||||
|
||||
/**
|
||||
* Absolute floor on the diff viewport. If the terminal is absurdly
|
||||
* short (< 26 rows total) we still show at least 8 diff lines so the
|
||||
* modal is usable; the user scrolls more aggressively but can't end up
|
||||
* with a zero-height viewport.
|
||||
*/
|
||||
const MIN_DIFF_ROWS = 8;
|
||||
|
||||
/**
|
||||
* Modal-style approval for a single model-proposed file edit.
|
||||
*
|
||||
* Why per-edit instead of end-of-turn batch: users reported they can't
|
||||
* tell if the model is about to make one small change or seven sprawling
|
||||
* ones until the turn is over. Per-edit prompts let the user intervene
|
||||
* early — reject a bad direction before the model builds on it, or
|
||||
* approve the first few and hit `a` to let the rest land.
|
||||
*
|
||||
* Diff viewport: we compute the UNCAPPED diff once, then slice to a
|
||||
* height budget derived from terminal rows. Large diffs (100+ lines)
|
||||
* are scrollable via j/k/↑/↓/Space/PgUp/PgDn/g/G — the user can page
|
||||
* through the whole change before deciding, and the action footer
|
||||
* never scrolls off the bottom. Before the scroll support a 97-line
|
||||
* scene.ts diff truncated at "… 67 more …" which was unreviewable.
|
||||
*/
|
||||
export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const budget = Math.max(MIN_DIFF_ROWS, rows - MODAL_OVERHEAD_ROWS);
|
||||
|
||||
// Full diff — maxLines pushed high enough that the diff-preview
|
||||
// renderer never inserts its own truncation marker. Context trimming
|
||||
// (contextLines) still applies for tight scan.
|
||||
const allLines = useMemo(
|
||||
() => formatEditBlockDiff(block, { contextLines: 2, maxLines: 100_000, indent: " " }),
|
||||
[block],
|
||||
);
|
||||
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const maxScroll = Math.max(0, allLines.length - budget);
|
||||
// Clamp scroll if budget shrinks (terminal resized) after a previous
|
||||
// keypress drove the offset past the new ceiling.
|
||||
const effectiveScroll = Math.min(scroll, maxScroll);
|
||||
|
||||
useInput((input, key) => {
|
||||
// Action keys first — decision wins over scroll so a user who
|
||||
// hammered PgDn can still hit `y` at the end without a stray
|
||||
// extra scroll.
|
||||
if (key.return || input === "y") {
|
||||
onChoose("apply");
|
||||
return;
|
||||
}
|
||||
if (input === "n") {
|
||||
onChoose("reject");
|
||||
return;
|
||||
}
|
||||
if (input === "a") {
|
||||
onChoose("apply-rest-of-turn");
|
||||
return;
|
||||
}
|
||||
if (input === "A") {
|
||||
onChoose("flip-to-auto");
|
||||
return;
|
||||
}
|
||||
// Scroll navigation — vim-ish (j/k) and emacs-ish (arrows) both
|
||||
// work. Space for "next page" is the pager convention.
|
||||
if (key.downArrow || input === "j") {
|
||||
setScroll((s) => Math.min(maxScroll, s + 1));
|
||||
return;
|
||||
}
|
||||
if (key.upArrow || input === "k") {
|
||||
setScroll((s) => Math.max(0, s - 1));
|
||||
return;
|
||||
}
|
||||
if (key.pageDown || input === " " || input === "f") {
|
||||
setScroll((s) => Math.min(maxScroll, s + Math.max(1, budget - 2)));
|
||||
return;
|
||||
}
|
||||
if (key.pageUp || input === "b") {
|
||||
setScroll((s) => Math.max(0, s - Math.max(1, budget - 2)));
|
||||
return;
|
||||
}
|
||||
if (input === "g") {
|
||||
setScroll(0);
|
||||
return;
|
||||
}
|
||||
if (input === "G") {
|
||||
setScroll(maxScroll);
|
||||
return;
|
||||
}
|
||||
// Esc falls through to the parent abort handler.
|
||||
});
|
||||
|
||||
const isNew = block.search === "";
|
||||
const removed = isNew ? 0 : (block.search.match(/\n/g)?.length ?? 0) + 1;
|
||||
const added = block.replace === "" ? 0 : (block.replace.match(/\n/g)?.length ?? 0) + 1;
|
||||
const tag = isNew ? "NEW" : "EDIT";
|
||||
|
||||
const visibleLines = allLines.slice(effectiveScroll, effectiveScroll + budget);
|
||||
const hiddenAbove = effectiveScroll;
|
||||
const hiddenBelow = Math.max(0, allLines.length - effectiveScroll - budget);
|
||||
const totalLines = allLines.length;
|
||||
const showScrollHud = hiddenAbove + hiddenBelow > 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{"▸ model wants to edit a file"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="cyan" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text color={isNew ? "green" : "yellow"} bold>{`[${tag}] `}</Text>
|
||||
<Text color="cyan">{block.path}</Text>
|
||||
<Text dimColor>{` (-${removed} +${added} lines)`}</Text>
|
||||
{showScrollHud ? (
|
||||
<Text dimColor>
|
||||
{` · viewing ${effectiveScroll + 1}-${effectiveScroll + visibleLines.length}/${totalLines}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
{hiddenAbove > 0 ? (
|
||||
<Text
|
||||
dimColor
|
||||
>{` ↑ ${hiddenAbove} line${hiddenAbove === 1 ? "" : "s"} above (↑/k or PgUp)`}</Text>
|
||||
) : null}
|
||||
<Box marginTop={hiddenAbove > 0 ? 0 : 1} flexDirection="column">
|
||||
{visibleLines.map((line, i) => {
|
||||
const trimmed = line.trimStart();
|
||||
const color = trimmed.startsWith("+")
|
||||
? "green"
|
||||
: trimmed.startsWith("-")
|
||||
? "red"
|
||||
: undefined;
|
||||
const dim = !color;
|
||||
return (
|
||||
<Text
|
||||
// Stable-enough: slice only shifts by scroll; React keys only
|
||||
// need to be unique within the current render.
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: list is static per render
|
||||
key={`diff-${effectiveScroll}-${i}`}
|
||||
color={color}
|
||||
dimColor={dim}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{hiddenBelow > 0 ? (
|
||||
<Text
|
||||
dimColor
|
||||
>{` ↓ ${hiddenBelow} line${hiddenBelow === 1 ? "" : "s"} below (↓/j or Space/PgDn)`}</Text>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{"["}
|
||||
<Text color="cyan" bold>
|
||||
y
|
||||
</Text>
|
||||
{"/Enter] apply · ["}
|
||||
<Text color="cyan" bold>
|
||||
n
|
||||
</Text>
|
||||
{"] reject · ["}
|
||||
<Text color="cyan" bold>
|
||||
a
|
||||
</Text>
|
||||
{"] apply rest · ["}
|
||||
<Text color="cyan" bold>
|
||||
A
|
||||
</Text>
|
||||
{"] flip AUTO · ["}
|
||||
<Text color="cyan" bold>
|
||||
↑↓/Space
|
||||
</Text>
|
||||
{"] scroll · [Esc] abort"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+57
-4
@@ -7,7 +7,21 @@ import { PlanStateBlock } from "./PlanStateBlock.js";
|
||||
import { Markdown } from "./markdown.js";
|
||||
import { useElapsedSeconds, useTick } from "./ticker.js";
|
||||
|
||||
export type DisplayRole = "user" | "assistant" | "tool" | "system" | "error" | "info" | "warning";
|
||||
export type DisplayRole =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "tool"
|
||||
| "system"
|
||||
| "error"
|
||||
| "info"
|
||||
| "warning"
|
||||
/**
|
||||
* A plan body pushed to scrollback when the model calls `submit_plan`.
|
||||
* Rendered through the full markdown pipeline (not truncated), so the
|
||||
* user reads the whole thing in the permanent Static log and the
|
||||
* PlanConfirm modal below it stays a tight picker.
|
||||
*/
|
||||
| "plan";
|
||||
|
||||
export interface DisplayEvent {
|
||||
id: string;
|
||||
@@ -21,7 +35,7 @@ export interface DisplayEvent {
|
||||
stats?: TurnStats;
|
||||
repair?: string;
|
||||
streaming?: boolean;
|
||||
toolCallBuild?: { name: string; chars: number };
|
||||
toolCallBuild?: { name: string; chars: number; index?: number; readyCount?: number };
|
||||
/**
|
||||
* Render a thin horizontal rule above this event. Used to mark
|
||||
* "start of a new user turn" so long scrollbacks get visual
|
||||
@@ -159,6 +173,20 @@ export const EventRow = React.memo(function EventRow({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "plan") {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{"📋 plan proposed — pick a choice below"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Markdown text={event.text} projectRoot={projectRoot} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "warning") {
|
||||
return (
|
||||
<Box>
|
||||
@@ -405,13 +433,15 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
|
||||
labelColor = "cyan";
|
||||
} else if (toolCallOnly) {
|
||||
label = `assembling tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments`;
|
||||
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
|
||||
labelColor = "magenta";
|
||||
} else {
|
||||
const parts: string[] = [`writing response · ${event.text.length} chars`];
|
||||
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
|
||||
if (toolCallBuild) {
|
||||
parts.push(`building tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars`);
|
||||
parts.push(
|
||||
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
|
||||
);
|
||||
}
|
||||
label = parts.join(" · ");
|
||||
labelColor = "green";
|
||||
@@ -469,6 +499,29 @@ function Pulse() {
|
||||
return <Text color="cyan">{frames[Math.floor(tick / 4) % frames.length]}</Text>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "(call 3)" suffix for the tool-call label when we have the index.
|
||||
* Hides when the turn only has one call so the common case stays tidy.
|
||||
*/
|
||||
function formatToolCallIndex(tb: { index?: number; readyCount?: number } | undefined): string {
|
||||
if (!tb || tb.index === undefined) return "";
|
||||
// Only show the index when this isn't obviously the first + only call.
|
||||
if (tb.index === 0 && (tb.readyCount ?? 0) === 0) return "";
|
||||
return ` (call ${tb.index + 1})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "· 2 ready" tail that answers "how many tool calls have finished
|
||||
* streaming so far this turn?" — user feedback for multi-file turns
|
||||
* where the response takes 10–30s to stream. Hidden until readyCount
|
||||
* > 0 so single-call turns aren't cluttered.
|
||||
*/
|
||||
function formatReadyTail(tb: { readyCount?: number } | undefined): string {
|
||||
const n = tb?.readyCount ?? 0;
|
||||
if (n <= 0) return "";
|
||||
return ` · ${n} ready`;
|
||||
}
|
||||
|
||||
function lastLine(s: string, maxChars: number): string {
|
||||
const flat = s.replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "";
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { ApplyResult } from "../../code/edit-blocks.js";
|
||||
import type { EditMode } from "../../config.js";
|
||||
import type { JobRegistry } from "../../tools/jobs.js";
|
||||
import { useElapsedSeconds, useTick } from "./ticker.js";
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/**
|
||||
* Transient "what's happening now" row shown during silent phases
|
||||
* between the primary events — harvest round-trip, R1 thinking
|
||||
* about a tool result before the next streaming delta, forced
|
||||
* summary. Matches OngoingToolRow's visual language (braille
|
||||
* spinner + elapsed seconds) so the user's eyes track the same
|
||||
* spot regardless of which kind of wait it is.
|
||||
*/
|
||||
export function StatusRow({ text }: { text: string }) {
|
||||
const tick = useTick();
|
||||
const elapsed = useElapsedSeconds();
|
||||
return (
|
||||
<Box marginY={1}>
|
||||
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="magenta">{` ${text}`}</Text>
|
||||
<Text dimColor>{` ${elapsed}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line bottom status bar showing the current edit mode, pending
|
||||
* queue size (review only), an undo hint (auto only / after /apply),
|
||||
* and the Shift+Tab nudge. Rendered immediately above PromptInput so
|
||||
* the mode is always in peripheral vision when the user's eyes are at
|
||||
* the prompt. Flashes briefly on mode change so Shift+Tab gives a
|
||||
* visible acknowledgment without the user having to scan the header.
|
||||
*
|
||||
* The plan-mode pill takes precedence — when plan mode is on, writes
|
||||
* are bounced regardless of edit mode, so surfacing it is more useful
|
||||
* than the review/auto toggle.
|
||||
*/
|
||||
export function ModeStatusBar({
|
||||
editMode,
|
||||
pendingCount,
|
||||
flash,
|
||||
planMode,
|
||||
undoArmed,
|
||||
jobs,
|
||||
}: {
|
||||
editMode: EditMode;
|
||||
pendingCount: number;
|
||||
flash: boolean;
|
||||
planMode: boolean;
|
||||
undoArmed: boolean;
|
||||
jobs?: JobRegistry;
|
||||
}) {
|
||||
// Subscribe to tick so the jobs count stays live — the registry is
|
||||
// mutated outside React, so we need a periodic repaint to catch it.
|
||||
// No-op when there's no registry (chat mode / tests).
|
||||
useTick();
|
||||
const running = jobs?.runningCount() ?? 0;
|
||||
const jobsTag =
|
||||
running > 0 ? (
|
||||
<Text color="yellow" bold>{` · ⏵ ${running} job${running === 1 ? "" : "s"}`}</Text>
|
||||
) : null;
|
||||
if (planMode) {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color="red" bold inverse={flash}>
|
||||
{"▸ PLAN"}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" writes gated — submit_plan + approval required · /plan off to leave"}
|
||||
</Text>
|
||||
{jobsTag}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const label = editMode === "auto" ? "AUTO" : "review";
|
||||
const color = editMode === "auto" ? "magenta" : "cyan";
|
||||
const mid =
|
||||
editMode === "auto"
|
||||
? undoArmed
|
||||
? "edits apply immediately · u = undo last batch"
|
||||
: "edits apply immediately · 5s undo window after each batch"
|
||||
: pendingCount > 0
|
||||
? `${pendingCount} queued · y = /apply · n = /discard`
|
||||
: "edits queue for review · y = /apply · n = /discard";
|
||||
const flip = editMode === "auto" ? "Shift+Tab → review" : "Shift+Tab → AUTO";
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={color} bold inverse={flash}>
|
||||
{`▸ ${label}`}
|
||||
</Text>
|
||||
<Text dimColor>{` ${mid} · ${flip}`}</Text>
|
||||
{jobsTag}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Just auto-applied N edits — press u to undo" banner. Rendered below
|
||||
* the live rows after an auto-mode edit batch lands, visible for 5s.
|
||||
* `useTick` drives a crude live countdown so the user sees the window
|
||||
* shrinking. State cleanup (the banner disappearing) happens in the
|
||||
* parent's setTimeout — the component is purely display.
|
||||
*/
|
||||
export function UndoBanner({
|
||||
banner,
|
||||
}: {
|
||||
banner: { results: ApplyResult[]; expiresAt: number };
|
||||
}) {
|
||||
useTick();
|
||||
const remainingMs = Math.max(0, banner.expiresAt - Date.now());
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const ok = banner.results.filter((r) => r.status === "applied" || r.status === "created").length;
|
||||
const total = banner.results.length;
|
||||
return (
|
||||
<Box marginY={1} borderStyle="round" borderColor="magenta" paddingX={1}>
|
||||
<Text color="magenta" bold>
|
||||
{"✓ auto-applied "}
|
||||
</Text>
|
||||
<Text color="magenta">{`${ok}/${total} edit${total === 1 ? "" : "s"}`}</Text>
|
||||
<Text dimColor>{" · press "}</Text>
|
||||
<Text color="magenta" bold>
|
||||
{"u"}
|
||||
</Text>
|
||||
<Text dimColor>{" to undo ("}</Text>
|
||||
<Text color={remainingSec <= 1 ? "red" : "magenta"}>{`${remainingSec}s`}</Text>
|
||||
<Text dimColor>{")"}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live one-line indicator for a running subagent. Sits below the
|
||||
* OngoingToolRow (the parent's tool dispatch row for `spawn_subagent`)
|
||||
* so the user sees both layers at once: outer "spawn_subagent
|
||||
* running…" + inner "⌬ subagent: <task> · iter N · 12.3s". Cleared
|
||||
* when the subagent ends; a one-line summary lands in historical.
|
||||
*/
|
||||
export function SubagentRow({
|
||||
activity,
|
||||
}: {
|
||||
activity: { task: string; iter: number; elapsedMs: number };
|
||||
}) {
|
||||
const tick = useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="magenta">{` ⌬ subagent: ${activity.task}`}</Text>
|
||||
<Text dimColor>{` · iter ${activity.iter} · ${seconds}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live spinner row while a tool call is in flight. Without this the
|
||||
* window between the model's `tool_calls` decision and the tool's
|
||||
* result (often seconds for a multi-KB `filesystem_edit_file`) looks
|
||||
* like the app has frozen — the streaming assistant display is
|
||||
* already cleared and the input is disabled, so there's nothing to
|
||||
* look at.
|
||||
*
|
||||
* Shows three signals: a braille spinner (liveness), an elapsed
|
||||
* timer in seconds (so "long" has a number attached), and a
|
||||
* per-tool summary of the most informative argument fields (path,
|
||||
* edits count, pattern, etc.). As of 0.4.8, MCP progress frames
|
||||
* (`notifications/progress`) land here too — bar + "n/N" when the
|
||||
* server reports a total, free-form message when not.
|
||||
*/
|
||||
export function OngoingToolRow({
|
||||
tool,
|
||||
progress,
|
||||
}: {
|
||||
tool: { name: string; args?: string };
|
||||
progress: { progress: number; total?: number; message?: string } | null;
|
||||
}) {
|
||||
const tick = useTick();
|
||||
const elapsed = useElapsedSeconds();
|
||||
const summary = summarizeToolArgs(tool.name, tool.args);
|
||||
return (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="yellow">{` tool<${tool.name}> running…`}</Text>
|
||||
<Text dimColor>{` ${elapsed}s`}</Text>
|
||||
</Box>
|
||||
{progress ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="cyan">{renderProgressLine(progress)}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{summary ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an MCP progress frame as a single line. When the server
|
||||
* reports `total`, show an ASCII progress bar + "n/total pct%";
|
||||
* otherwise just "progress" + optional message. Width is modest
|
||||
* so the line fits even in a narrow terminal.
|
||||
*/
|
||||
function renderProgressLine(p: { progress: number; total?: number; message?: string }): string {
|
||||
const msg = p.message ? ` ${p.message}` : "";
|
||||
if (p.total && p.total > 0) {
|
||||
const ratio = Math.max(0, Math.min(1, p.progress / p.total));
|
||||
const width = 20;
|
||||
const filled = Math.round(ratio * width);
|
||||
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
||||
const pct = (ratio * 100).toFixed(0);
|
||||
return `[${bar}] ${p.progress}/${p.total} ${pct}%${msg}`;
|
||||
}
|
||||
return `progress: ${p.progress}${msg}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn raw JSON tool arguments into a one-line human summary. For
|
||||
* common filesystem MCP tools we pull the actually-useful fields; for
|
||||
* anything else we fall back to a truncated raw string so the user
|
||||
* still sees *something* beyond the tool name.
|
||||
*
|
||||
* Match on suffix (e.g. `_read_file`) rather than exact name because
|
||||
* `bridgeMcpTools({ namePrefix: "filesystem_" })` prepends the server
|
||||
* namespace — tools arrive as `filesystem_read_file` in practice but
|
||||
* callers might wire up anonymous too.
|
||||
*/
|
||||
function summarizeToolArgs(name: string, args?: string): string {
|
||||
if (!args || args === "{}") return "";
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(args) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Unparseable JSON — show a head slice so user at least sees
|
||||
// what the model tried.
|
||||
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
|
||||
}
|
||||
const hasSuffix = (s: string) => name === s || name.endsWith(`_${s}`);
|
||||
const path = typeof parsed.path === "string" ? parsed.path : undefined;
|
||||
if (hasSuffix("read_file")) {
|
||||
const head = typeof parsed.head === "number" ? `, head=${parsed.head}` : "";
|
||||
const tail = typeof parsed.tail === "number" ? `, tail=${parsed.tail}` : "";
|
||||
return `path: ${path ?? "?"}${head}${tail}`;
|
||||
}
|
||||
if (hasSuffix("write_file")) {
|
||||
const content = typeof parsed.content === "string" ? parsed.content : "";
|
||||
return `path: ${path ?? "?"} (${content.length} chars)`;
|
||||
}
|
||||
if (hasSuffix("edit_file")) {
|
||||
const edits = Array.isArray(parsed.edits) ? parsed.edits.length : 0;
|
||||
return `path: ${path ?? "?"} (${edits} edit${edits === 1 ? "" : "s"})`;
|
||||
}
|
||||
if (hasSuffix("list_directory") || hasSuffix("directory_tree")) {
|
||||
return `path: ${path ?? "?"}`;
|
||||
}
|
||||
if (hasSuffix("search_files")) {
|
||||
const pattern = typeof parsed.pattern === "string" ? parsed.pattern : "?";
|
||||
return `path: ${path ?? "?"} · pattern: ${pattern}`;
|
||||
}
|
||||
if (hasSuffix("move_file")) {
|
||||
const src = typeof parsed.source === "string" ? parsed.source : "?";
|
||||
const dst = typeof parsed.destination === "string" ? parsed.destination : "?";
|
||||
return `${src} → ${dst}`;
|
||||
}
|
||||
if (hasSuffix("get_file_info")) {
|
||||
return `path: ${path ?? "?"}`;
|
||||
}
|
||||
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
|
||||
}
|
||||
+10
-81
@@ -9,91 +9,28 @@
|
||||
* 3. Cancel — exits plan mode, drops the plan, tells the model the
|
||||
* user didn't want any of it.
|
||||
*
|
||||
* Mirrors ShellConfirm in structure (border, SingleSelect, three
|
||||
* options, no y/n hotkey — mid-typing triggers would be painful).
|
||||
* The plan body is rendered verbatim above the picker so the user can
|
||||
* actually read what they're approving.
|
||||
* The plan BODY is NOT rendered inside this modal — App.tsx pushes it
|
||||
* into the Static scrollback as a `role: "plan"` display event the
|
||||
* moment submit_plan fires. That's a one-time markdown render into
|
||||
* permanent history, so the user reads the full proposal above and
|
||||
* the modal below stays a tight picker. The previous design (body
|
||||
* rendered inside the modal with an aggressive line clamp) left users
|
||||
* staring at "(25 more lines truncated)" and unable to approve safely.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { Markdown } from "./markdown.js";
|
||||
|
||||
export type PlanConfirmChoice = "approve" | "refine" | "cancel";
|
||||
|
||||
export interface PlanConfirmProps {
|
||||
plan: string;
|
||||
onChoose: (choice: PlanConfirmChoice) => void;
|
||||
/**
|
||||
* Cap on rendered plan length. A pathological 20-KB plan would push
|
||||
* the picker off the bottom of the terminal; we show the head +
|
||||
* "(…N chars truncated — /tool for full output)" instead. The picker
|
||||
* itself gets the full plan (it's already been committed to the
|
||||
* transcript via the tool result).
|
||||
*/
|
||||
maxRenderedChars?: number;
|
||||
projectRoot?: string;
|
||||
/**
|
||||
* Override the terminal-row count used for vertical clamping. Tests
|
||||
* pass this to avoid depending on `process.stdout.rows`; runtime
|
||||
* callers leave it unset and we read the real TTY size.
|
||||
*/
|
||||
terminalRows?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RENDERED = 2400;
|
||||
/**
|
||||
* Reserved rows for the picker chrome (border + header + divider +
|
||||
* open-questions hint + SingleSelect with three options + footer + the
|
||||
* assistant-turn block Ink already printed above). Empirically ~16 on a
|
||||
* wide terminal; we round up to 18 to leave a visible strip of the plan
|
||||
* even on a 24-row default instead of pushing the picker out of view.
|
||||
*
|
||||
* When the terminal is this tall or shorter we fall through to a
|
||||
* minimum of 6 rendered body lines so the user sees SOMETHING rather
|
||||
* than a single-line stub.
|
||||
*/
|
||||
const PICKER_CHROME_ROWS = 18;
|
||||
const MIN_BODY_ROWS = 6;
|
||||
|
||||
/**
|
||||
* Trim `text` to the first `maxLines` lines, appending a truncation
|
||||
* marker when it was cut. Each line is clamped to ~ terminal width so
|
||||
* wrapped lines don't double-count against the row budget — we can't
|
||||
* know the exact rendered height of a Markdown block (headings, code
|
||||
* fences, list bullets add chrome), so the row cap is conservative on
|
||||
* purpose.
|
||||
*/
|
||||
export function clampBodyByLines(text: string, maxLines: number): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= maxLines) return text;
|
||||
const kept = lines.slice(0, maxLines).join("\n");
|
||||
const dropped = lines.length - maxLines;
|
||||
return `${kept}\n\n… (${dropped} more lines truncated — resize the terminal to see more, or /tool for the full proposal)`;
|
||||
}
|
||||
|
||||
function PlanConfirmInner({
|
||||
plan,
|
||||
onChoose,
|
||||
maxRenderedChars,
|
||||
projectRoot,
|
||||
terminalRows,
|
||||
}: PlanConfirmProps) {
|
||||
const cap = maxRenderedChars ?? DEFAULT_MAX_RENDERED;
|
||||
const charTrunc = plan.length > cap;
|
||||
const charCapped = charTrunc
|
||||
? `${plan.slice(0, cap)}\n\n… (${plan.length - cap} chars truncated — use /tool to view the full proposal)`
|
||||
: plan;
|
||||
// Vertical clamp. Flicker root-cause: when rendered content exceeds
|
||||
// the TTY row count Ink falls back to "clear + full redraw" each
|
||||
// frame, and every parent re-render redraws the big Markdown subtree.
|
||||
// Capping the body to (rows - picker chrome) keeps the whole modal
|
||||
// inside the terminal so Ink uses incremental diffs and the picker
|
||||
// stops thrashing. See Bug A in 0.5.14.
|
||||
const rows = terminalRows ?? process.stdout?.rows ?? 24;
|
||||
const bodyRowBudget = Math.max(MIN_BODY_ROWS, rows - PICKER_CHROME_ROWS);
|
||||
const visible = clampBodyByLines(charCapped, bodyRowBudget);
|
||||
function PlanConfirmInner({ plan, onChoose }: PlanConfirmProps) {
|
||||
// Crude signal for "the model left questions or risks for me" — the
|
||||
// typical section headings. Triggers an extra hint toward the Refine
|
||||
// option so users know where to answer them.
|
||||
@@ -105,21 +42,13 @@ function PlanConfirmInner({
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
▸ plan submitted — awaiting your review
|
||||
▸ plan proposed (full text above) — approve / refine / cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="cyan" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Markdown text={visible} projectRoot={projectRoot} />
|
||||
</Box>
|
||||
{hasOpenQuestions ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">
|
||||
▲ the plan has open questions or flagged risks — pick{" "}
|
||||
▲ the plan flags open questions or risks — pick{" "}
|
||||
<Text bold>Refine / answer questions</Text> to write concrete answers before the model
|
||||
moves on.
|
||||
</Text>
|
||||
|
||||
@@ -59,6 +59,7 @@ export function ReplayApp({ meta, pages }: ReplayAppProps) {
|
||||
cacheHitRatio: cumStats.cacheHitRatio,
|
||||
// Replay is read-only — no live last-turn prompt tokens to show.
|
||||
lastPromptTokens: 0,
|
||||
lastTurnCostUsd: 0,
|
||||
};
|
||||
|
||||
const prefixHash =
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface ShellConfirmProps {
|
||||
* "always allow". Typically the first 1-2 tokens of `command`.
|
||||
*/
|
||||
allowPrefix: string;
|
||||
/**
|
||||
* Which tool is asking. `run_background` spawns via JobRegistry and
|
||||
* returns early; `run_command` (default) blocks until the process
|
||||
* exits. Shown as a hint in the modal so the user knows whether
|
||||
* approving will block the TUI or not.
|
||||
*/
|
||||
kind?: "run_command" | "run_background";
|
||||
onChoose: (choice: ShellConfirmChoice) => void;
|
||||
}
|
||||
|
||||
@@ -25,14 +32,24 @@ export interface ShellConfirmProps {
|
||||
* Arrow keys + Enter. No y/n hotkey — too easy to trigger by accident
|
||||
* when the user was mid-typing a response.
|
||||
*/
|
||||
export function ShellConfirm({ command, allowPrefix, onChoose }: ShellConfirmProps) {
|
||||
export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConfirmProps) {
|
||||
const isBackground = kind === "run_background";
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="yellow">
|
||||
▸ model wants to run a shell command
|
||||
{isBackground
|
||||
? "▸ model wants to start a BACKGROUND process"
|
||||
: "▸ model wants to run a shell command"}
|
||||
</Text>
|
||||
</Box>
|
||||
{isBackground ? (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{" (long-running: dev server / watcher; keeps running after approval, /kill to stop)"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text color="yellow" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import type { EditMode } from "../../config.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
|
||||
import type { SessionSummary } from "../../telemetry.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
@@ -56,6 +57,13 @@ export interface StatsPanelProps {
|
||||
prefixHash: string;
|
||||
harvestOn?: boolean;
|
||||
branchBudget?: number;
|
||||
/**
|
||||
* Current `reasoning_effort` cap. Shown as a green "· max" / yellow
|
||||
* "· high" tag in the header so the user always sees which tier the
|
||||
* next turn will use. Absent/undefined hides the tag (e.g. before
|
||||
* the loop is constructed).
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
/**
|
||||
* True when `reasonix code` is currently running in read-only Plan
|
||||
* Mode. Surfaced as a red "PLAN" tag in the panel header so the user
|
||||
@@ -63,6 +71,12 @@ export interface StatsPanelProps {
|
||||
* approval.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* Edit-gate mode. Surfaced as a small pill in the header so the user
|
||||
* can see at a glance whether edits will be queued for review or
|
||||
* applied immediately. Omitted in chat (non-code) mode.
|
||||
*/
|
||||
editMode?: EditMode;
|
||||
/**
|
||||
* Account balance fetched once at launch (and optionally refreshed
|
||||
* per-turn by the TUI). `null` or absent hides the balance cell
|
||||
@@ -85,6 +99,19 @@ export interface StatsPanelProps {
|
||||
* visible at a glance from anywhere on screen.
|
||||
*/
|
||||
busy?: boolean;
|
||||
/**
|
||||
* /pro is queued — the next turn will run on v4-pro regardless of
|
||||
* `model`. Rendered as a yellow `⇧ pro armed` pill in the header so
|
||||
* the user has a clear "this turn will be expensive" signal before
|
||||
* submitting their message.
|
||||
*/
|
||||
proArmed?: boolean;
|
||||
/**
|
||||
* The CURRENT turn is running on v4-pro because the failure-
|
||||
* escalation threshold fired mid-turn. Rendered as a red `⇧ pro
|
||||
* escalated` pill. Clears at turn end.
|
||||
*/
|
||||
escalated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,10 +137,14 @@ export function StatsPanel({
|
||||
prefixHash,
|
||||
harvestOn,
|
||||
branchBudget,
|
||||
reasoningEffort,
|
||||
planMode,
|
||||
editMode,
|
||||
balance,
|
||||
updateAvailable,
|
||||
busy,
|
||||
proArmed,
|
||||
escalated,
|
||||
}: StatsPanelProps) {
|
||||
const branchOn = (branchBudget ?? 1) > 1;
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
@@ -135,11 +166,15 @@ export function StatsPanel({
|
||||
harvestOn={harvestOn}
|
||||
branchOn={branchOn}
|
||||
branchBudget={branchBudget ?? 1}
|
||||
reasoningEffort={reasoningEffort}
|
||||
planMode={planMode}
|
||||
editMode={editMode}
|
||||
turns={summary.turns}
|
||||
updateAvailable={updateAvailable}
|
||||
narrow={narrow}
|
||||
busy={busy ?? false}
|
||||
proArmed={proArmed ?? false}
|
||||
escalated={escalated ?? false}
|
||||
/>
|
||||
{narrow ? (
|
||||
<StackedMetrics
|
||||
@@ -168,22 +203,30 @@ function Header({
|
||||
harvestOn,
|
||||
branchOn,
|
||||
branchBudget,
|
||||
reasoningEffort,
|
||||
planMode,
|
||||
editMode,
|
||||
turns,
|
||||
updateAvailable,
|
||||
narrow,
|
||||
busy,
|
||||
proArmed,
|
||||
escalated,
|
||||
}: {
|
||||
model: string;
|
||||
prefixHash: string;
|
||||
harvestOn?: boolean;
|
||||
branchOn: boolean;
|
||||
branchBudget: number;
|
||||
reasoningEffort?: "high" | "max";
|
||||
planMode?: boolean;
|
||||
editMode?: EditMode;
|
||||
turns: number;
|
||||
updateAvailable?: string | null;
|
||||
narrow: boolean;
|
||||
busy: boolean;
|
||||
proArmed: boolean;
|
||||
escalated: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box justifyContent="space-between">
|
||||
@@ -200,11 +243,27 @@ function Header({
|
||||
)}
|
||||
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
|
||||
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
|
||||
{reasoningEffort === "max" ? <Text color="green"> · max</Text> : null}
|
||||
{reasoningEffort === "high" ? <Text color="yellow"> · high</Text> : null}
|
||||
{planMode ? (
|
||||
<Text color="red" bold>
|
||||
{" · PLAN"}
|
||||
</Text>
|
||||
) : null}
|
||||
{editMode ? (
|
||||
<Text color={editMode === "auto" ? "magenta" : "cyan"} bold>
|
||||
{editMode === "auto" ? " · AUTO" : " · review"}
|
||||
</Text>
|
||||
) : null}
|
||||
{escalated ? (
|
||||
<Text color="red" bold>
|
||||
{" · ⇧ pro escalated"}
|
||||
</Text>
|
||||
) : proArmed ? (
|
||||
<Text color="yellow" bold>
|
||||
{" · ⇧ pro armed"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text>
|
||||
{updateAvailable ? (
|
||||
@@ -357,6 +416,24 @@ function CacheCell({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color thresholds. Per-turn and session cumulative scale roughly 10×
|
||||
* apart — a $0.20 single turn should feel just as warn-worthy as a $2
|
||||
* session total. Values are rough "notice this" points, not hard caps.
|
||||
*/
|
||||
function turnCostColor(cost: number): "green" | "yellow" | "red" | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 0.2) return "red";
|
||||
if (cost >= 0.05) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
function sessionCostColor(cost: number): "green" | "yellow" | "red" | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return "red";
|
||||
if (cost >= 0.5) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
|
||||
function CostCell({
|
||||
summary,
|
||||
coldStart,
|
||||
@@ -376,17 +453,17 @@ function CostCell({
|
||||
// so the dollar figure is front-loaded. Muting it during the
|
||||
// cold-start window keeps the "expensive first turn" from
|
||||
// reading as "something is wrong."
|
||||
const primaryColor = coldStart ? undefined : "green";
|
||||
const turnColor = coldStart ? undefined : turnCostColor(summary.lastTurnCostUsd);
|
||||
const sessionColor = coldStart ? undefined : sessionCostColor(summary.totalCostUsd);
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cost </Text>
|
||||
<Text color={primaryColor} bold={!coldStart} dimColor={coldStart}>
|
||||
${summary.totalCostUsd.toFixed(6)}
|
||||
<Text dimColor>turn </Text>
|
||||
<Text color={turnColor} bold={!coldStart} dimColor={coldStart}>
|
||||
${summary.lastTurnCostUsd.toFixed(4)}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" (in "}${summary.totalInputCostUsd.toFixed(6)}
|
||||
{" · out "}${summary.totalOutputCostUsd.toFixed(6)}
|
||||
{")"}
|
||||
<Text dimColor>{" · session "}</Text>
|
||||
<Text color={sessionColor} bold={!coldStart} dimColor={coldStart}>
|
||||
${summary.totalCostUsd.toFixed(4)}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { formatAllBlockDiffs } from "../../code/diff-preview.js";
|
||||
import type { ApplyResult, EditBlock, EditSnapshot } from "../../code/edit-blocks.js";
|
||||
|
||||
/**
|
||||
* One batch of edits that actually landed on disk — durable enough for
|
||||
* `/undo`, `/history`, and `/show` within a session. Not persisted
|
||||
* across restarts: restoring pre-apply content from a process that
|
||||
* crashed last week is git's job, not ours.
|
||||
*/
|
||||
export interface EditHistoryEntry {
|
||||
/** Sequence number within the session, stable for `/show <id>`. */
|
||||
id: number;
|
||||
/** Epoch ms when the entry was opened (first edit landed). */
|
||||
at: number;
|
||||
/**
|
||||
* Short tag for what produced the batch — "auto" (auto-mode tool
|
||||
* call), "auto-text" (auto-mode text SEARCH/REPLACE at turn end),
|
||||
* "review-apply" (user-approved modal edit or /apply flush).
|
||||
*/
|
||||
source: string;
|
||||
/** Edit blocks included in this batch, in arrival order. */
|
||||
blocks: EditBlock[];
|
||||
/** Per-block outcome — some may be "not-found" if SEARCH drifted. */
|
||||
results: ApplyResult[];
|
||||
/**
|
||||
* First-snapshot-per-path wins: this is what `/undo` restores to.
|
||||
* Deduped so multi-edit turns still roll back to pre-turn state.
|
||||
*/
|
||||
snapshots: EditSnapshot[];
|
||||
/**
|
||||
* Paths within this entry that have already been reverted (via
|
||||
* `/undo <id>`, `/undo <id> <path>`, or the newest-non-undone /undo
|
||||
* shortcut). Per-path instead of entry-level so a batch can be
|
||||
* partially undone — user reverts src/foo.ts out of a 3-file batch
|
||||
* without rolling back the other two.
|
||||
*/
|
||||
undoneFiles: Set<string>;
|
||||
}
|
||||
|
||||
/** True when every path in the entry has been undone. */
|
||||
export function isEntryFullyUndone(e: EditHistoryEntry): boolean {
|
||||
return e.snapshots.length > 0 && e.snapshots.every((s) => e.undoneFiles.has(s.path));
|
||||
}
|
||||
|
||||
/** Per-entry three-state status label for display. */
|
||||
export function entryStatus(e: EditHistoryEntry): "applied" | "UNDONE" | "PARTIAL" {
|
||||
if (e.undoneFiles.size === 0) return "applied";
|
||||
if (isEntryFullyUndone(e)) return "UNDONE";
|
||||
return "PARTIAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a batch of SEARCH/REPLACE application results as one
|
||||
* human-scannable info line per edit. Prefixes denote status so the
|
||||
* line reads well even without color (e.g. when piped to a log file
|
||||
* or stripped for screenshots):
|
||||
* ✓ applied src/foo.ts
|
||||
* ✓ created src/new.ts
|
||||
* ✗ not-found src/bar.ts (SEARCH text does not match…)
|
||||
*/
|
||||
export function formatEditResults(results: ApplyResult[]): string {
|
||||
const lines = results.map((r) => {
|
||||
const mark = r.status === "applied" || r.status === "created" ? "✓" : "✗";
|
||||
const detail = r.message ? ` (${r.message})` : "";
|
||||
return ` ${mark} ${r.status.padEnd(11)} ${r.path}${detail}`;
|
||||
});
|
||||
const ok = results.filter((r) => r.status === "applied" || r.status === "created").length;
|
||||
const total = results.length;
|
||||
const header = `▸ edit blocks: ${ok}/${total} applied — /undo to roll back, or \`git diff\` to review`;
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-edits preview shown after each assistant turn that proposed
|
||||
* changes. Per-block path header + ±line-count, then a unified-diff-
|
||||
* style preview (context trimmed to 2 lines each side, total capped
|
||||
* at 20 lines per block). Users can eyeball what's about to land
|
||||
* BEFORE pressing `y` — the old summary-only view was a common
|
||||
* mistake surface.
|
||||
*/
|
||||
export function formatPendingPreview(blocks: EditBlock[]): string {
|
||||
const header = `▸ ${blocks.length} pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop`;
|
||||
const diffLines = formatAllBlockDiffs(blocks);
|
||||
return [header, ...diffLines].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file rows for the multi-level `/undo` output, without the
|
||||
* single-batch header (the caller prepends its own).
|
||||
*/
|
||||
export function formatUndoRows(results: ApplyResult[]): string[] {
|
||||
return results.map((r) => {
|
||||
const mark = r.status === "applied" ? "✓" : "✗";
|
||||
const detail = r.message ? ` (${r.message})` : "";
|
||||
return ` ${mark} ${r.path}${detail}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function describeRepair(repair: {
|
||||
scavenged: number;
|
||||
truncationsFixed: number;
|
||||
stormsBroken: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (repair.scavenged) parts.push(`scavenged ${repair.scavenged}`);
|
||||
if (repair.truncationsFixed) parts.push(`repaired ${repair.truncationsFixed} truncation`);
|
||||
if (repair.stormsBroken) parts.push(`broke ${repair.stormsBroken} storm`);
|
||||
return parts.length ? `[repair] ${parts.join(", ")}` : "";
|
||||
}
|
||||
@@ -195,7 +195,15 @@ export function parseCitationUrl(url: string): CitationParts | null {
|
||||
export function validateCitation(url: string, projectRoot: string): CitationStatus {
|
||||
const parts = parseCitationUrl(url);
|
||||
if (!parts || !parts.path) return { ok: false, reason: "empty path" };
|
||||
const fullPath = isAbsolute(parts.path) ? parts.path : join(projectRoot, parts.path);
|
||||
// Strip a leading `/` or `\\`. Models habitually write `/foo.ts`
|
||||
// meaning "project-root relative" — Aider / Claude-Code convention —
|
||||
// rather than POSIX root-absolute. A literal absolute path is still
|
||||
// reachable as `C:\foo` on Windows or once the strip drops the slash
|
||||
// and Node's `isAbsolute` correctly rejects what's left. Real unix
|
||||
// absolute references like `/etc/hosts` in a code citation are vanishingly
|
||||
// rare — if anyone needs one we can revisit.
|
||||
const normalized = parts.path.replace(/^[/\\]+/, "");
|
||||
const fullPath = isAbsolute(normalized) ? normalized : join(projectRoot, normalized);
|
||||
let stat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
stat = statSync(fullPath);
|
||||
|
||||
+50
-13
@@ -3,40 +3,77 @@
|
||||
* (in-chat `/preset`) and the wizard (first-run setup) read from here.
|
||||
*
|
||||
* Presets are the single vocabulary we teach new users: they don't need
|
||||
* to know model IDs, Pillar 2, branch budgets, or cost tradeoffs
|
||||
* independently — they pick "fast / smart / max" and we translate.
|
||||
* to know model IDs, reasoning effort, or thinking mode independently
|
||||
* — they pick "fast / smart / max" and we translate.
|
||||
*
|
||||
* Design rules (v0.6+):
|
||||
* - Branching (`branch >= 2`) is NEVER in a preset. Self-consistency
|
||||
* sampling is `N×` cost; auto-enabling it would ambush users into
|
||||
* multi-dollar turns without asking. Opt-in only via `/branch N`.
|
||||
* - Harvest (Pillar-2 plan-state extraction) is NEVER in a preset.
|
||||
* In practice it's display sugar — the typed plan state hasn't
|
||||
* fed back into orchestration decisions (branch trigger) often
|
||||
* enough to pay for the extra round-trip. Opt-in only via
|
||||
* `/harvest on`.
|
||||
* - The three tiers differ on only TWO knobs: model (flash/pro) and
|
||||
* reasoning effort (high/max). Same on-the-wire billing axis,
|
||||
* easy to reason about, easy to budget.
|
||||
*/
|
||||
|
||||
import type { PresetName } from "../../config.js";
|
||||
|
||||
export interface PresetSettings {
|
||||
model: string;
|
||||
/**
|
||||
* Reasoning-effort cap sent per turn. `high` = shorter chain of
|
||||
* thought (cheaper, faster); `max` = agent-class default (deeper,
|
||||
* more output tokens). DeepSeek ignores this field on non-thinking
|
||||
* calls, so it's safe to pin across models.
|
||||
*/
|
||||
reasoningEffort: "high" | "max";
|
||||
/**
|
||||
* Pillar-2 plan-state extraction. Every preset ships `false` (see
|
||||
* module header); kept in the shape so a user who later flips it on
|
||||
* at runtime still round-trips cleanly. `/harvest on` toggles it.
|
||||
*/
|
||||
harvest: boolean;
|
||||
/** Branch budget. `1` means branching off. */
|
||||
/**
|
||||
* Branch budget. Every preset ships `1` (off). `/branch N` with
|
||||
* N>=2 is the only way to enable self-consistency sampling.
|
||||
*/
|
||||
branch: number;
|
||||
}
|
||||
|
||||
export const PRESETS: Record<PresetName, PresetSettings> = {
|
||||
fast: { model: "deepseek-chat", harvest: false, branch: 1 },
|
||||
smart: { model: "deepseek-reasoner", harvest: true, branch: 1 },
|
||||
max: { model: "deepseek-reasoner", harvest: true, branch: 3 },
|
||||
// fast — flash + effort=high. Quick Q&A, one-line tweaks, anything
|
||||
// where shallow reasoning is enough. Cheapest turn possible.
|
||||
fast: { model: "deepseek-v4-flash", reasoningEffort: "high", harvest: false, branch: 1 },
|
||||
// smart — flash + effort=max. Full thinking budget on the cheap
|
||||
// model. The default: handles 90%+ of coding work at a fraction
|
||||
// of pro's cost.
|
||||
smart: { model: "deepseek-v4-flash", reasoningEffort: "max", harvest: false, branch: 1 },
|
||||
// max — pro + effort=max. Frontier model for hard tasks: cross-
|
||||
// file architecture, subtle bug hunts, anything where flash's
|
||||
// reasoning has measurably failed. ~12× per-token vs flash; save
|
||||
// for when you need it, or use `/pro` to escalate a single turn.
|
||||
max: { model: "deepseek-v4-pro", reasoningEffort: "max", harvest: false, branch: 1 },
|
||||
};
|
||||
|
||||
export const PRESET_DESCRIPTIONS: Record<PresetName, { headline: string; cost: string }> = {
|
||||
fast: {
|
||||
headline: "deepseek-chat, no reasoning harvest, no branching",
|
||||
cost: "~1¢ per 100 turns · default",
|
||||
headline: "v4-flash · effort=high",
|
||||
cost: "cheapest · quick Q&A, one-line edits",
|
||||
},
|
||||
smart: {
|
||||
headline: "deepseek-reasoner + Pillar 2 harvest",
|
||||
cost: "~10× cost vs fast · slower · better on multi-step tasks",
|
||||
headline: "v4-flash · effort=max",
|
||||
cost: "~1.5× fast · default · day-to-day coding",
|
||||
},
|
||||
max: {
|
||||
headline: "reasoner + harvest + self-consistency (3 branches)",
|
||||
cost: "~30× cost vs fast · slowest · for hard single-shots",
|
||||
headline: "v4-pro · effort=max",
|
||||
cost: "~12× fast · hard single-shots · use /pro for a single-turn bump",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolvePreset(name: PresetName | undefined): PresetSettings {
|
||||
return PRESETS[name ?? "fast"];
|
||||
return PRESETS[name ?? "smart"];
|
||||
}
|
||||
+20
-1553
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,213 @@
|
||||
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
|
||||
|
||||
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
{ cmd: "help", summary: "show the full command reference" },
|
||||
{ cmd: "status", summary: "current model, flags, context, session" },
|
||||
{
|
||||
cmd: "preset",
|
||||
argsHint: "<fast|smart|max>",
|
||||
summary: "one-tap model + harvest + branch bundle",
|
||||
argCompleter: ["fast", "smart", "max"],
|
||||
},
|
||||
{
|
||||
cmd: "model",
|
||||
argsHint: "<id>",
|
||||
summary: "switch DeepSeek model id",
|
||||
argCompleter: "models",
|
||||
},
|
||||
{ cmd: "models", summary: "list available models fetched from DeepSeek /models" },
|
||||
{
|
||||
cmd: "harvest",
|
||||
argsHint: "[on|off]",
|
||||
summary: "toggle Pillar-2 plan-state extraction",
|
||||
argCompleter: ["on", "off"],
|
||||
},
|
||||
{
|
||||
cmd: "branch",
|
||||
argsHint: "<N|off>",
|
||||
summary: "run N parallel samples per turn (N>=2)",
|
||||
argCompleter: ["off", "2", "3", "4", "5"],
|
||||
},
|
||||
{
|
||||
cmd: "effort",
|
||||
argsHint: "<high|max>",
|
||||
summary: "reasoning_effort cap — max is default (agent-class), high is cheaper/faster",
|
||||
argCompleter: ["max", "high"],
|
||||
},
|
||||
{
|
||||
cmd: "pro",
|
||||
argsHint: "[off]",
|
||||
summary: "arm v4-pro for the NEXT turn only (one-shot · auto-disarms after turn)",
|
||||
argCompleter: ["off"],
|
||||
},
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{
|
||||
cmd: "resource",
|
||||
argsHint: "[uri]",
|
||||
summary: "browse + read MCP resources (no arg → list URIs; <uri> → fetch contents)",
|
||||
argCompleter: "mcp-resources",
|
||||
},
|
||||
{
|
||||
cmd: "prompt",
|
||||
argsHint: "[name]",
|
||||
summary: "browse + fetch MCP prompts (no arg → list names; <name> → render prompt)",
|
||||
argCompleter: "mcp-prompts",
|
||||
},
|
||||
{ cmd: "tool", argsHint: "[N]", summary: "dump full output of the Nth tool call (1=latest)" },
|
||||
{
|
||||
cmd: "memory",
|
||||
argsHint: "[list|show <name>|forget <name>|clear <scope> confirm]",
|
||||
summary: "show / manage pinned memory (REASONIX.md + ~/.reasonix/memory)",
|
||||
},
|
||||
{
|
||||
cmd: "skill",
|
||||
argsHint: "[list|show <name>|<name> [args]]",
|
||||
summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
|
||||
},
|
||||
{
|
||||
cmd: "hooks",
|
||||
argsHint: "[reload]",
|
||||
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
|
||||
},
|
||||
{
|
||||
cmd: "update",
|
||||
summary: "show current vs latest version + the shell command to upgrade",
|
||||
},
|
||||
{
|
||||
cmd: "stats",
|
||||
summary:
|
||||
"cross-session cost dashboard (today / week / month / all-time · cache hit · vs Claude)",
|
||||
},
|
||||
{ cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
|
||||
{
|
||||
cmd: "context",
|
||||
summary: "break down where context tokens are going: system / tools / per-turn log",
|
||||
},
|
||||
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
|
||||
{
|
||||
cmd: "compact",
|
||||
argsHint: "[tokens]",
|
||||
summary:
|
||||
"shrink oversized tool results AND tool-call args (edit_file search/replace) in the log; cap in tokens, default 4000",
|
||||
},
|
||||
{ cmd: "keys", summary: "show all keyboard shortcuts and prompt prefixes" },
|
||||
{ cmd: "sessions", summary: "list saved sessions (current marked with ▸)" },
|
||||
{ cmd: "forget", summary: "delete the current session from disk" },
|
||||
{ cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
|
||||
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
|
||||
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
|
||||
{ cmd: "exit", summary: "quit the TUI" },
|
||||
// Code-mode only
|
||||
{ cmd: "apply", summary: "commit pending edit blocks to disk", contextual: "code" },
|
||||
{ cmd: "discard", summary: "drop pending edit blocks without writing", contextual: "code" },
|
||||
{ cmd: "undo", summary: "roll back the last applied edit batch", contextual: "code" },
|
||||
{
|
||||
cmd: "history",
|
||||
summary: "list every edit batch this session (ids for /show, undone markers)",
|
||||
contextual: "code",
|
||||
},
|
||||
{
|
||||
cmd: "show",
|
||||
argsHint: "[id]",
|
||||
summary: "dump a stored edit diff (omit id for newest non-undone)",
|
||||
contextual: "code",
|
||||
},
|
||||
{
|
||||
cmd: "commit",
|
||||
argsHint: '"msg"',
|
||||
summary: "git add -A && git commit -m ...",
|
||||
contextual: "code",
|
||||
},
|
||||
{
|
||||
cmd: "plan",
|
||||
argsHint: "[on|off]",
|
||||
summary: "toggle read-only plan mode (writes bounced until submit_plan + approval)",
|
||||
contextual: "code",
|
||||
argCompleter: ["on", "off"],
|
||||
},
|
||||
{
|
||||
cmd: "apply-plan",
|
||||
summary: "force-approve a pending / in-text plan (fallback if picker was missed)",
|
||||
contextual: "code",
|
||||
},
|
||||
{
|
||||
cmd: "mode",
|
||||
argsHint: "[review|auto]",
|
||||
summary: "edit-gate: review (queue for /apply) or auto (apply+undo banner). Shift+Tab cycles.",
|
||||
contextual: "code",
|
||||
argCompleter: ["review", "auto"],
|
||||
},
|
||||
{ cmd: "jobs", summary: "list background jobs started by run_background", contextual: "code" },
|
||||
{
|
||||
cmd: "kill",
|
||||
argsHint: "<id>",
|
||||
summary: "stop a background job by id (SIGTERM → SIGKILL after grace)",
|
||||
contextual: "code",
|
||||
},
|
||||
{
|
||||
cmd: "logs",
|
||||
argsHint: "<id> [lines]",
|
||||
summary: "tail a background job's output (default last 80 lines)",
|
||||
contextual: "code",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter the registry by a prefix string (without the leading `/`).
|
||||
* Empty prefix returns the full non-contextual list (plus code-mode
|
||||
* entries when `codeMode` is true). Case-insensitive.
|
||||
*/
|
||||
export function suggestSlashCommands(prefix: string, codeMode = false): SlashCommandSpec[] {
|
||||
const p = prefix.toLowerCase();
|
||||
return SLASH_COMMANDS.filter((c) => {
|
||||
if (c.contextual === "code" && !codeMode) return false;
|
||||
return c.cmd.startsWith(p);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the prompt buffer for argument completion. Returns `null`
|
||||
* when the buffer isn't in a slash-with-args state.
|
||||
*
|
||||
* Firing shape: input must start with `/<cmd> ` (space after a known
|
||||
* command). The character right after the space through end-of-buffer
|
||||
* is the "arg tail"; if the tail has NO internal whitespace the
|
||||
* picker is live (tail is the partial). If the tail has whitespace,
|
||||
* the user has moved past the first argument position and we surface
|
||||
* the usage hint only.
|
||||
*/
|
||||
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
|
||||
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
|
||||
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
|
||||
if (!m) return null;
|
||||
const cmdName = m[1]!.toLowerCase();
|
||||
const tail = m[2] ?? "";
|
||||
const spec = SLASH_COMMANDS.find(
|
||||
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
|
||||
);
|
||||
if (!spec) return null;
|
||||
const hasInternalSpace = /\s/.test(tail);
|
||||
const partialOffset = input.length - tail.length;
|
||||
if (hasInternalSpace) {
|
||||
// Past the first arg position (typing the second arg for e.g.
|
||||
// `/edit <file> <instruction>`, or mid-sentence for free-form).
|
||||
return { spec, partial: tail, partialOffset, kind: "hint" };
|
||||
}
|
||||
// No internal whitespace — we're still typing the first arg. Picker
|
||||
// is live if the spec declares a completer; otherwise show a hint
|
||||
// ("what goes here?") since the user's guessing.
|
||||
return {
|
||||
spec,
|
||||
partial: tail,
|
||||
partialOffset,
|
||||
kind: spec.argCompleter ? "picker" : "hint",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlash(text: string): { cmd: string; args: string[] } | null {
|
||||
if (!text.startsWith("/")) return null;
|
||||
const parts = text.slice(1).trim().split(/\s+/);
|
||||
const cmd = parts[0]?.toLowerCase() ?? "";
|
||||
if (!cmd) return null;
|
||||
return { cmd, args: parts.slice(1) };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CacheFirstLoop } from "../../../loop.js";
|
||||
import { handlers as adminHandlers } from "./handlers/admin.js";
|
||||
import { handlers as basicHandlers } from "./handlers/basic.js";
|
||||
import { handlers as editsHandlers } from "./handlers/edits.js";
|
||||
import { handlers as jobsHandlers } from "./handlers/jobs.js";
|
||||
import { handlers as mcpHandlers } from "./handlers/mcp.js";
|
||||
import { handlers as memoryHandlers } from "./handlers/memory.js";
|
||||
import { handlers as modelHandlers } from "./handlers/model.js";
|
||||
import { handlers as observabilityHandlers } from "./handlers/observability.js";
|
||||
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
|
||||
import { handlers as skillHandlers } from "./handlers/skill.js";
|
||||
import type { SlashContext, SlashResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* A slash handler receives the command's args + the loop + shared
|
||||
* runtime context, and returns a synchronous SlashResult. Async work
|
||||
* (balance fetches, stop_job) fires-and-forgets via `ctx.postInfo`
|
||||
* instead — keeps the TUI input non-blocking.
|
||||
*/
|
||||
export type SlashHandler = (args: string[], loop: CacheFirstLoop, ctx: SlashContext) => SlashResult;
|
||||
|
||||
/**
|
||||
* Flat map of cmd → handler. Composed from per-topic modules so each
|
||||
* feature's logic stays local; aliases (e.g. `exit` / `quit`) are just
|
||||
* extra keys in the per-module handlers bag pointing at the same fn.
|
||||
*
|
||||
* Ordering of the spread matters only for duplicate keys; there are
|
||||
* none. Keep alphabetical for diff sanity when adding modules.
|
||||
*/
|
||||
const HANDLERS: Record<string, SlashHandler> = {
|
||||
...adminHandlers,
|
||||
...basicHandlers,
|
||||
...editsHandlers,
|
||||
...jobsHandlers,
|
||||
...mcpHandlers,
|
||||
...memoryHandlers,
|
||||
...modelHandlers,
|
||||
...observabilityHandlers,
|
||||
...sessionsHandlers,
|
||||
...skillHandlers,
|
||||
};
|
||||
|
||||
export function handleSlash(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
loop: CacheFirstLoop,
|
||||
ctx: SlashContext = {},
|
||||
): SlashResult {
|
||||
const h = HANDLERS[cmd];
|
||||
if (h) return h(args, loop, ctx);
|
||||
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
HOOK_EVENTS,
|
||||
type HookEvent,
|
||||
type ResolvedHook,
|
||||
globalSettingsPath,
|
||||
projectSettingsPath,
|
||||
} from "../../../../hooks.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../../../usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../../../version.js";
|
||||
import { renderDashboard } from "../../../commands/stats.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const hooks: SlashHandler = (args, loop, ctx) => {
|
||||
const sub = (args[0] ?? "").toLowerCase();
|
||||
|
||||
if (sub === "reload") {
|
||||
if (!ctx.reloadHooks) {
|
||||
return {
|
||||
info: "/hooks reload is not available in this context (no reload callback wired).",
|
||||
};
|
||||
}
|
||||
const count = ctx.reloadHooks();
|
||||
return { info: `▸ reloaded hooks · ${count} active` };
|
||||
}
|
||||
|
||||
if (sub !== "" && sub !== "list" && sub !== "ls") {
|
||||
return {
|
||||
info: "usage: /hooks list active hooks\n /hooks reload re-read settings.json files",
|
||||
};
|
||||
}
|
||||
|
||||
const all = loop.hooks;
|
||||
const projPath = ctx.codeRoot ? projectSettingsPath(ctx.codeRoot) : undefined;
|
||||
const globPath = globalSettingsPath();
|
||||
if (all.length === 0) {
|
||||
const lines = [
|
||||
"no hooks configured.",
|
||||
"",
|
||||
"drop a settings.json with a `hooks` key into either of:",
|
||||
ctx.codeRoot
|
||||
? ` · ${projPath} (project)`
|
||||
: " · <project>/.reasonix/settings.json (project)",
|
||||
` · ${globPath} (global)`,
|
||||
"",
|
||||
"events: PreToolUse, PostToolUse, UserPromptSubmit, Stop",
|
||||
"exit 0 = pass · exit 2 = block (Pre*) · other = warn",
|
||||
];
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
const grouped = new Map<HookEvent, ResolvedHook[]>();
|
||||
for (const event of HOOK_EVENTS) grouped.set(event, []);
|
||||
for (const h of all) grouped.get(h.event)?.push(h);
|
||||
|
||||
const lines: string[] = [`▸ ${all.length} hook(s) loaded`];
|
||||
for (const event of HOOK_EVENTS) {
|
||||
const list = grouped.get(event) ?? [];
|
||||
if (list.length === 0) continue;
|
||||
lines.push("", `${event}:`);
|
||||
for (const h of list) {
|
||||
const match = h.match && h.match !== "*" ? ` match=${h.match}` : "";
|
||||
const desc = h.description ? ` — ${h.description}` : "";
|
||||
lines.push(` [${h.scope}]${match} ${h.command}${desc}`);
|
||||
}
|
||||
}
|
||||
lines.push("", `sources: project=${projPath ?? "(none — chat mode)"} · global=${globPath}`);
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
/**
|
||||
* `/update` — inside the TUI we deliberately do NOT spawn `npm install`.
|
||||
* stdio:inherit into a running Ink renderer corrupts the display, and
|
||||
* the process being upgraded is the same process that's still reading
|
||||
* its own binaries (messy on Windows). Instead we surface what we
|
||||
* already know from the App's background registry check and print the
|
||||
* exact shell command the user should run after exiting.
|
||||
*
|
||||
* The `latestVersion` ctx field is populated by App.tsx's mount-time
|
||||
* `getLatestVersion()` effect. When it's `null` we report the check
|
||||
* as pending/offline — still a useful output (current version + how
|
||||
* to force a fresh check from another terminal).
|
||||
*/
|
||||
const update: SlashHandler = (_args, _loop, ctx) => {
|
||||
const latest = ctx.latestVersion ?? null;
|
||||
const lines: string[] = [`current: reasonix ${VERSION}`];
|
||||
if (latest === null) {
|
||||
// Kick off a fresh fetch so a follow-up /update a few seconds
|
||||
// later has a real answer instead of the same pending message.
|
||||
ctx.refreshLatestVersion?.();
|
||||
lines.push(
|
||||
"latest: (not yet resolved — background check in flight or offline)",
|
||||
"",
|
||||
"triggered a fresh registry fetch — retry `/update` in a few seconds,",
|
||||
"or run `reasonix update` in another terminal to force it synchronously.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
lines.push(`latest: reasonix ${latest}`);
|
||||
const diff = compareVersions(VERSION, latest);
|
||||
if (diff >= 0) {
|
||||
lines.push("", "you're on the latest. nothing to do.");
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
if (isNpxInstall()) {
|
||||
lines.push(
|
||||
"",
|
||||
"you're running via npx — the next `npx reasonix ...` launch will auto-fetch.",
|
||||
"to force a refresh sooner: `npm cache clean --force`.",
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"",
|
||||
"to upgrade, exit this session and run:",
|
||||
" reasonix update (interactive, dry-run supported via --dry-run)",
|
||||
" npm install -g reasonix@latest (direct)",
|
||||
"",
|
||||
"in-session install is deliberately disabled — the npm spawn would",
|
||||
"corrupt this TUI's rendering and Windows can lock the running binary.",
|
||||
);
|
||||
}
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
/**
|
||||
* `/stats` — dashboard view of `~/.reasonix/usage.jsonl`, the same
|
||||
* roll-up `reasonix stats` (no arg) prints at the shell. Synchronous
|
||||
* disk read; cheap enough that we don't bother caching between slash
|
||||
* invocations.
|
||||
*
|
||||
* No transcript-path variant in-TUI: the per-file summary is scripty
|
||||
* and rarely wanted mid-session. If someone needs it they have the
|
||||
* CLI form (`reasonix stats <path>`).
|
||||
*/
|
||||
const stats: SlashHandler = () => {
|
||||
const path = defaultUsageLogPath();
|
||||
const records = readUsageLog(path);
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
info: [
|
||||
"no usage data yet.",
|
||||
"",
|
||||
` ${path}`,
|
||||
"",
|
||||
"every turn you run here appends one record — this session's turns",
|
||||
"will show up in the dashboard once you send a message.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
const agg = aggregateUsage(records);
|
||||
return { info: renderDashboard(agg, path) };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
hook: hooks,
|
||||
hooks,
|
||||
update,
|
||||
stats,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const exit: SlashHandler = () => ({ exit: true });
|
||||
|
||||
const clear: SlashHandler = () => ({
|
||||
clear: true,
|
||||
info: "▸ cleared visible scrollback only. Context (message log) is intact — next turn still sees everything. Use /new to start fresh, or /forget to delete the session entirely.",
|
||||
});
|
||||
|
||||
const resetLog: SlashHandler = (_args, loop) => {
|
||||
// Actually drop the in-memory log + rewrite the session file so the
|
||||
// NEXT call has no prior context. Keeps session name, model, and
|
||||
// other config — just the conversation is reset.
|
||||
const { dropped } = loop.clearLog();
|
||||
return {
|
||||
clear: true,
|
||||
info: `▸ new conversation — dropped ${dropped} message(s) from context. Same session, fresh slate.`,
|
||||
};
|
||||
};
|
||||
|
||||
const keys: SlashHandler = () => ({
|
||||
info: [
|
||||
"Keyboard & prompt shortcuts:",
|
||||
"",
|
||||
" Enter submit the current prompt",
|
||||
" Shift+Enter / Ctrl+J insert a newline (multi-line prompt)",
|
||||
" \\<Enter> bash-style line continuation",
|
||||
" ← → ↑ ↓ move cursor / recall history when buffer empty",
|
||||
" Ctrl+A / Ctrl+E jump to start / end of the current line",
|
||||
" Backspace delete left; Delete delete under cursor",
|
||||
" Esc abort the in-flight turn",
|
||||
" y / n accept / reject pending edits (code mode)",
|
||||
" Shift+Tab cycle edit gate: review ↔ AUTO (code mode, persists to config)",
|
||||
" u undo the latest non-undone edit batch (session-wide, not just banner)",
|
||||
"",
|
||||
"Prompt prefixes:",
|
||||
" /<name> slash command; Tab/Enter picks from the suggestion list",
|
||||
" @<path> inline a file under [Referenced files] (code mode).",
|
||||
" Trailing `@…` opens a file picker; ↑/↓ navigate, Tab/Enter pick.",
|
||||
" !<cmd> run <cmd> as shell in the sandbox root; output goes into context",
|
||||
" so the model sees it next turn. No allowlist gate.",
|
||||
"",
|
||||
"Pickers (slash + @-mention):",
|
||||
" ↑ / ↓ navigate the suggestion list",
|
||||
" Tab insert the highlighted item without submitting",
|
||||
" Enter insert and (slash) run it, (@) keep editing",
|
||||
"",
|
||||
"MCP exploration:",
|
||||
" /mcp servers + tool/resource/prompt counts",
|
||||
" /resource [uri] browse & read resources exposed by your MCP servers",
|
||||
" /prompt [name] browse & fetch prompts exposed by your MCP servers",
|
||||
"",
|
||||
"Useful slashes: /help · /context · /stats · /compact · /new · /exit",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const help: SlashHandler = () => ({
|
||||
info: [
|
||||
"Commands:",
|
||||
" /help this message",
|
||||
" /keys keyboard shortcuts + prompt prefixes (!, @, /)",
|
||||
" /status show current settings",
|
||||
" /preset <fast|smart|max> one-tap presets — see below",
|
||||
" /model <id> deepseek-v4-flash or deepseek-v4-pro",
|
||||
" /pro [off] arm v4-pro for NEXT turn only (one-shot, auto-disarms)",
|
||||
" /harvest [on|off] Pillar 2: structured plan-state extraction (OPT-IN — costs extra)",
|
||||
" /branch <N|off> run N parallel samples (N>=2) — MANUAL ONLY, N× cost",
|
||||
" /effort <high|max> reasoning_effort cap (max=full thinking, high=cheaper/faster)",
|
||||
" /mcp list MCP servers + tools attached to this session",
|
||||
" /resource [uri] browse + read MCP resources (no arg → list URIs; <uri> → fetch)",
|
||||
" /prompt [name] browse + fetch MCP prompts (no arg → list names; <name> → render)",
|
||||
" /setup (exit + reconfigure) → run `reasonix setup`",
|
||||
" /compact [tokens] shrink large tool results in history (default 4000 tokens/result)",
|
||||
" /think dump the most recent turn's full R1 reasoning (reasoner only)",
|
||||
" /tool [N] list tool calls (or dump full output of #N, 1=most recent)",
|
||||
" /memory [sub] show pinned memory (REASONIX.md + ~/.reasonix/memory).",
|
||||
" subs: list | show <name> | forget <name> | clear <scope> confirm",
|
||||
" /skill [sub] list / run user skills (project/.reasonix/skills + ~/.reasonix/skills).",
|
||||
" subs: list | show <name> | <name> [args] (injects skill body as user turn)",
|
||||
" /retry truncate & resend your last message (fresh sample from the model)",
|
||||
" /apply (code mode) commit the pending edit blocks to disk",
|
||||
" /discard (code mode) drop pending edits without writing",
|
||||
" /undo (code mode) roll back the latest non-undone edit batch",
|
||||
" /history (code mode) list every edit batch this session",
|
||||
" /show [id] (code mode) dump a stored edit diff (newest when id omitted)",
|
||||
' /commit "msg" (code mode) git add -A && git commit -m "msg"',
|
||||
" /plan [on|off] (code mode) toggle read-only plan mode; writes gated behind submit_plan + your approval",
|
||||
" /apply-plan (code mode) force-approve pending/in-text plan (fallback)",
|
||||
" /mode [review|auto] (code mode) edit-gate: queue edits for /apply or apply instantly (Shift+Tab cycles, u undoes within 5s)",
|
||||
" /jobs (code mode) list background processes (run_background) — running and exited",
|
||||
" /kill <id> (code mode) stop a background job by id (SIGTERM → SIGKILL)",
|
||||
" /logs <id> [lines] (code mode) tail a background job's output (default 80 lines)",
|
||||
" /sessions list saved sessions (current is marked with ▸)",
|
||||
" /forget delete the current session from disk",
|
||||
" /new start fresh: drop all context + clear scrollback",
|
||||
" /clear clear displayed scrollback only (context kept — model still sees it)",
|
||||
" /exit quit",
|
||||
"",
|
||||
"Shell shortcut:",
|
||||
" !<cmd> run <cmd> in the sandbox root; output goes into",
|
||||
" the conversation so the model sees it next turn.",
|
||||
" No allowlist gate — user-typed = explicit consent.",
|
||||
" Example: !git status !ls src/ !npm test",
|
||||
"",
|
||||
"File references (code mode):",
|
||||
" @path/to/file inline file content under [Referenced files] on send.",
|
||||
" Type `@` to open the picker (↑↓ navigate, Tab/Enter pick).",
|
||||
"",
|
||||
"Presets (branch + harvest are NEVER auto-enabled — opt-in only):",
|
||||
" fast v4-flash · effort=high cheapest · quick Q&A, one-line edits",
|
||||
" smart v4-flash · effort=max ← default · day-to-day coding",
|
||||
" max v4-pro · effort=max ~12× fast · hard single-shots",
|
||||
"",
|
||||
"Sessions (auto-enabled by default, named 'default'):",
|
||||
" reasonix chat --session <name> use a different named session",
|
||||
" reasonix chat --no-session disable persistence for this run",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const setup: SlashHandler = () => ({
|
||||
info:
|
||||
"To reconfigure (preset, MCP servers, API key), exit this chat and run " +
|
||||
"`reasonix setup`. Changes take effect on next launch.",
|
||||
});
|
||||
|
||||
const retry: SlashHandler = (_args, loop) => {
|
||||
const prev = loop.retryLastUser();
|
||||
if (!prev) {
|
||||
return {
|
||||
info: "nothing to retry — no prior user message in this session's log.",
|
||||
};
|
||||
}
|
||||
const preview = prev.length > 80 ? `${prev.slice(0, 80)}…` : prev;
|
||||
return {
|
||||
info: `▸ retrying: "${preview}"`,
|
||||
resubmit: prev,
|
||||
};
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
exit,
|
||||
quit: exit,
|
||||
clear,
|
||||
new: resetLog,
|
||||
reset: resetLog,
|
||||
keys,
|
||||
help,
|
||||
"?": help,
|
||||
setup,
|
||||
retry,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { EditMode } from "../../../../config.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { runGitCommit, stripOuterQuotes } from "../helpers.js";
|
||||
|
||||
const undo: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.codeUndo) {
|
||||
return {
|
||||
info: "/undo is only available inside `reasonix code` — chat mode doesn't apply edits.",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeUndo(args) };
|
||||
};
|
||||
|
||||
const history: SlashHandler = (_args, _loop, ctx) => {
|
||||
if (!ctx.codeHistory) {
|
||||
return { info: "/history is only available inside `reasonix code`." };
|
||||
}
|
||||
return { info: ctx.codeHistory() };
|
||||
};
|
||||
|
||||
const show: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.codeShowEdit) {
|
||||
return { info: "/show is only available inside `reasonix code`." };
|
||||
}
|
||||
return { info: ctx.codeShowEdit(args) };
|
||||
};
|
||||
|
||||
const apply: SlashHandler = (_args, _loop, ctx) => {
|
||||
if (!ctx.codeApply) {
|
||||
return {
|
||||
info: "/apply is only available inside `reasonix code` (nothing to apply here).",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeApply() };
|
||||
};
|
||||
|
||||
const discard: SlashHandler = (_args, _loop, ctx) => {
|
||||
if (!ctx.codeDiscard) {
|
||||
return {
|
||||
info: "/discard is only available inside `reasonix code`.",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeDiscard() };
|
||||
};
|
||||
|
||||
const plan: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.setPlanMode) {
|
||||
return {
|
||||
info: "/plan is only available inside `reasonix code` — chat mode doesn't gate tool writes.",
|
||||
};
|
||||
}
|
||||
const currentOn = Boolean(ctx.planMode);
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
let target: boolean;
|
||||
if (raw === "on" || raw === "true" || raw === "1") target = true;
|
||||
else if (raw === "off" || raw === "false" || raw === "0") target = false;
|
||||
else target = !currentOn;
|
||||
ctx.setPlanMode(target);
|
||||
if (target) {
|
||||
return {
|
||||
info: "▸ plan mode ON — write tools are gated; the model MUST call `submit_plan` before anything executes. (The model can also call submit_plan on its own for big tasks even when plan mode is off — this toggle is the stronger, explicit constraint.) Type /plan off to leave.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
info: "▸ plan mode OFF — write tools are live again. Model can still propose plans autonomously for large tasks.",
|
||||
};
|
||||
};
|
||||
|
||||
const applyPlan: SlashHandler = (_args, _loop, ctx) => {
|
||||
if (!ctx.setPlanMode) {
|
||||
return {
|
||||
info: "/apply-plan is only available inside `reasonix code`.",
|
||||
};
|
||||
}
|
||||
ctx.setPlanMode(false);
|
||||
ctx.clearPendingPlan?.();
|
||||
return {
|
||||
info: "▸ plan approved — implementing",
|
||||
resubmit:
|
||||
"The plan above has been approved. Implement it now. You are out of plan mode — use edit_file / write_file / run_command as needed. Stick to the plan unless you discover a concrete reason to deviate; if you do, tell me and wait for a response before making that deviation.",
|
||||
};
|
||||
};
|
||||
|
||||
const mode: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.setEditMode) {
|
||||
return {
|
||||
info: "/mode is only available inside `reasonix code`.",
|
||||
};
|
||||
}
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
const current = ctx.editMode ?? "review";
|
||||
let target: EditMode;
|
||||
if (raw === "review") target = "review";
|
||||
else if (raw === "auto") target = "auto";
|
||||
else if (raw === "") {
|
||||
// Bare /mode toggles, mirroring Shift+Tab. Users who just want to
|
||||
// see current mode without flipping can read /status.
|
||||
target = current === "auto" ? "review" : "auto";
|
||||
} else {
|
||||
return { info: "usage: /mode <review|auto> (Shift+Tab also cycles)" };
|
||||
}
|
||||
ctx.setEditMode(target);
|
||||
return {
|
||||
info:
|
||||
target === "auto"
|
||||
? "▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo, or /undo later"
|
||||
: "▸ edit mode: review — edits queue for /apply (or y) / /discard (or n)",
|
||||
};
|
||||
};
|
||||
|
||||
const commit: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.codeRoot) {
|
||||
return {
|
||||
info: "/commit is only available inside `reasonix code` (needs a rooted git repo).",
|
||||
};
|
||||
}
|
||||
// Reassemble the original argv. The parser lowercases cmd but leaves
|
||||
// args alone, and the TUI splits on whitespace which mangles quoted
|
||||
// messages — rejoin with spaces and strip a surrounding pair of
|
||||
// double quotes if the user wrote them.
|
||||
const raw = args.join(" ").trim();
|
||||
const message = stripOuterQuotes(raw);
|
||||
if (!message) {
|
||||
return {
|
||||
info: `usage: /commit "your commit message" — runs \`git add -A && git commit -m "…"\` in ${ctx.codeRoot}`,
|
||||
};
|
||||
}
|
||||
return runGitCommit(ctx.codeRoot, message);
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
undo,
|
||||
history,
|
||||
show,
|
||||
apply,
|
||||
discard,
|
||||
plan,
|
||||
"apply-plan": applyPlan,
|
||||
applyplan: applyPlan,
|
||||
mode,
|
||||
commit,
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const jobs: SlashHandler = (_args, _loop, ctx) => {
|
||||
if (!ctx.jobs) {
|
||||
return { info: "/jobs is only available inside `reasonix code`." };
|
||||
}
|
||||
const rows = ctx.jobs.list();
|
||||
if (rows.length === 0) {
|
||||
return { info: "no background jobs yet — use run_background to start one" };
|
||||
}
|
||||
const lines = ["Background jobs:"];
|
||||
for (const r of rows) {
|
||||
const age = ((Date.now() - r.startedAt) / 1000).toFixed(1);
|
||||
const state = r.running
|
||||
? `running · pid ${r.pid ?? "?"}`
|
||||
: r.exitCode !== null
|
||||
? `exit ${r.exitCode}`
|
||||
: r.spawnError
|
||||
? "failed"
|
||||
: "stopped";
|
||||
lines.push(
|
||||
` ${String(r.id).padStart(3)} ${state.padEnd(20)} ${age.padStart(6)}s ago $ ${r.command}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("/kill <id> to stop one · /logs <id> [lines] to tail output");
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
const kill: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.jobs) return { info: "/kill is only available inside `reasonix code`." };
|
||||
const id = Number.parseInt(args[0] ?? "", 10);
|
||||
if (!Number.isFinite(id)) return { info: "usage: /kill <id> (see /jobs for ids)" };
|
||||
const rec = ctx.jobs.list().find((r) => r.id === id);
|
||||
if (!rec) return { info: `job ${id}: not found` };
|
||||
if (!rec.running) return { info: `job ${id} already exited (${rec.exitCode ?? "?"})` };
|
||||
// Fire-and-forget: the registry waits for grace + SIGKILL internally;
|
||||
// returning immediately keeps the slash synchronous so the user's
|
||||
// prompt doesn't lock up for 2s on every /kill. The postInfo
|
||||
// callback lands a follow-up row in historical when the kill
|
||||
// actually completes, so the user sees "job N stopped" without
|
||||
// polling /jobs.
|
||||
const jobsRef = ctx.jobs;
|
||||
void (async () => {
|
||||
const final = await jobsRef.stop(id);
|
||||
if (!final) return;
|
||||
const status = final.running
|
||||
? "still alive after SIGKILL (!) — report this as a bug"
|
||||
: final.exitCode !== null
|
||||
? `exit ${final.exitCode}`
|
||||
: "stopped";
|
||||
ctx.postInfo?.(`▸ job ${id} ${status}`);
|
||||
})();
|
||||
return {
|
||||
info: `▸ stopping job ${id} (tree kill: SIGTERM → SIGKILL after 2s grace; Windows: taskkill /T /F)`,
|
||||
};
|
||||
};
|
||||
|
||||
const logs: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!ctx.jobs) return { info: "/logs is only available inside `reasonix code`." };
|
||||
const id = Number.parseInt(args[0] ?? "", 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return { info: "usage: /logs <id> [lines] (default last 80 lines)" };
|
||||
}
|
||||
const requested = Number.parseInt(args[1] ?? "", 10);
|
||||
const tail = Number.isFinite(requested) && requested > 0 ? requested : 80;
|
||||
const out = ctx.jobs.read(id, { tailLines: tail });
|
||||
if (!out) return { info: `job ${id}: not found` };
|
||||
const status = out.running
|
||||
? `running · pid ${out.pid ?? "?"}`
|
||||
: out.exitCode !== null
|
||||
? `exited ${out.exitCode}`
|
||||
: out.spawnError
|
||||
? `failed (${out.spawnError})`
|
||||
: "stopped";
|
||||
const header = `[job ${id} · ${status}]\n$ ${out.command}`;
|
||||
return { info: out.output ? `${header}\n${out.output}` : header };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
jobs,
|
||||
kill,
|
||||
logs,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { appendSection } from "../helpers.js";
|
||||
|
||||
const mcp: SlashHandler = (_args, loop, ctx) => {
|
||||
const servers = ctx.mcpServers ?? [];
|
||||
const specs = ctx.mcpSpecs ?? [];
|
||||
const toolSpecs = loop.prefix.toolSpecs ?? [];
|
||||
if (servers.length === 0 && specs.length === 0 && toolSpecs.length === 0) {
|
||||
return {
|
||||
info:
|
||||
"no MCP servers attached. Run `reasonix setup` to pick some, " +
|
||||
'or launch with --mcp "<spec>". `reasonix mcp list` shows the catalog.',
|
||||
};
|
||||
}
|
||||
// Rich path — we have full inspection reports, so show each server
|
||||
// with its tools / resources / prompts grouped together.
|
||||
if (servers.length > 0) {
|
||||
const lines: string[] = [];
|
||||
let anyResources = false;
|
||||
let anyPrompts = false;
|
||||
for (const s of servers) {
|
||||
const { report } = s;
|
||||
const serverName = report.serverInfo.name || "(unknown)";
|
||||
const serverVer = report.serverInfo.version ? ` v${report.serverInfo.version}` : "";
|
||||
lines.push(`[${s.label}] ${serverName}${serverVer} — ${s.spec}`);
|
||||
lines.push(` tools ${s.toolCount}`);
|
||||
appendSection(lines, "resources", report.resources);
|
||||
appendSection(lines, "prompts ", report.prompts);
|
||||
if (report.resources.supported && report.resources.items.length > 0) anyResources = true;
|
||||
if (report.prompts.supported && report.prompts.items.length > 0) anyPrompts = true;
|
||||
lines.push("");
|
||||
}
|
||||
if (anyResources || anyPrompts) {
|
||||
const hints: string[] = [];
|
||||
if (anyResources) hints.push("`/resource` to browse+read");
|
||||
if (anyPrompts) hints.push("`/prompt` to browse+fetch");
|
||||
lines.push(hints.join(" · "));
|
||||
} else {
|
||||
lines.push(
|
||||
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"Full catalog: `reasonix mcp list` · deeper diagnosis: `reasonix mcp inspect <spec>`.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
// Fallback — older path when the TUI hasn't populated `mcpServers`.
|
||||
const lines: string[] = [];
|
||||
if (specs.length > 0) {
|
||||
lines.push(`MCP servers (${specs.length}):`);
|
||||
for (const spec of specs) lines.push(` · ${spec}`);
|
||||
lines.push("");
|
||||
}
|
||||
if (toolSpecs.length > 0) {
|
||||
lines.push(`Tools in registry (${toolSpecs.length}):`);
|
||||
for (const t of toolSpecs) lines.push(` · ${t.function.name}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("To change this set, exit and run `reasonix setup`.");
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = { mcp };
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
PROJECT_MEMORY_FILE,
|
||||
memoryEnabled,
|
||||
readProjectMemory,
|
||||
} from "../../../../project-memory.js";
|
||||
import { type MemoryScope, MemoryStore } from "../../../../user-memory.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { resolveMemoryTarget } from "../helpers.js";
|
||||
|
||||
/**
|
||||
* `/memory` family. Bare `/memory` shows what's pinned (REASONIX.md +
|
||||
* both MEMORY.md blocks). Subcommands manage the user-memory store:
|
||||
* list — every memory file, both scopes
|
||||
* show <name> — dump one file's body
|
||||
* show <scope>/<name> — disambiguate when name exists in both scopes
|
||||
* forget <name> — delete (same scope resolution as show)
|
||||
* clear <scope> confirm — wipe a scope (typed literal "confirm" required)
|
||||
*/
|
||||
const memory: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!memoryEnabled()) {
|
||||
return {
|
||||
info: "memory is disabled (REASONIX_MEMORY=off in env). Unset the var to re-enable — no REASONIX.md or ~/.reasonix/memory content will be pinned in the meantime.",
|
||||
};
|
||||
}
|
||||
if (!ctx.memoryRoot) {
|
||||
return {
|
||||
info: "no working directory on this session — `/memory` needs a root to resolve REASONIX.md from. (Running in a test harness?)",
|
||||
};
|
||||
}
|
||||
// `codeRoot` is set only when running `reasonix code`. Chat mode has
|
||||
// `memoryRoot` = cwd (for REASONIX.md), but we don't treat cwd as a
|
||||
// sandbox — project-scope user memory requires a real code-mode root.
|
||||
const store = new MemoryStore({ projectRoot: ctx.codeRoot });
|
||||
const sub = (args[0] ?? "").toLowerCase();
|
||||
|
||||
if (sub === "list" || sub === "ls") {
|
||||
const entries = store.list();
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
info: "no user memories yet. The model can call `remember` to save one, or you can create files by hand in ~/.reasonix/memory/global/ or the per-project subdir.",
|
||||
};
|
||||
}
|
||||
const lines = [`User memories (${entries.length}):`];
|
||||
for (const e of entries) {
|
||||
const tag = `${e.scope}/${e.type}`.padEnd(18);
|
||||
const name = e.name.padEnd(28);
|
||||
const desc = e.description.length > 70 ? `${e.description.slice(0, 69)}…` : e.description;
|
||||
lines.push(` ${tag} ${name} ${desc}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("View body: /memory show <name> Delete: /memory forget <name>");
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
if (sub === "show" || sub === "cat") {
|
||||
const target = args[1];
|
||||
if (!target) return { info: "usage: /memory show <name> or /memory show <scope>/<name>" };
|
||||
const resolved = resolveMemoryTarget(store, target);
|
||||
if (!resolved) return { info: `no memory found: ${target}` };
|
||||
try {
|
||||
const entry = store.read(resolved.scope, resolved.name);
|
||||
return {
|
||||
info: [
|
||||
`▸ ${entry.scope}/${entry.name} (${entry.type}, created ${entry.createdAt || "?"})`,
|
||||
entry.description ? ` ${entry.description}` : "",
|
||||
"",
|
||||
entry.body,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.concat("")
|
||||
.join("\n"),
|
||||
};
|
||||
} catch (err) {
|
||||
return { info: `show failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "forget" || sub === "rm" || sub === "delete") {
|
||||
const target = args[1];
|
||||
if (!target) return { info: "usage: /memory forget <name> or /memory forget <scope>/<name>" };
|
||||
const resolved = resolveMemoryTarget(store, target);
|
||||
if (!resolved) return { info: `no memory found: ${target}` };
|
||||
try {
|
||||
const ok = store.delete(resolved.scope, resolved.name);
|
||||
return {
|
||||
info: ok
|
||||
? `▸ forgot ${resolved.scope}/${resolved.name}. Next /new or launch won't see it.`
|
||||
: `could not forget ${resolved.scope}/${resolved.name} (already gone?)`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { info: `forget failed: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "clear") {
|
||||
const rawScope = (args[1] ?? "").toLowerCase();
|
||||
if (rawScope !== "global" && rawScope !== "project") {
|
||||
return { info: "usage: /memory clear <global|project> confirm" };
|
||||
}
|
||||
if ((args[2] ?? "").toLowerCase() !== "confirm") {
|
||||
return {
|
||||
info: `about to delete every memory in scope=${rawScope}. Re-run with the word 'confirm' to proceed: /memory clear ${rawScope} confirm`,
|
||||
};
|
||||
}
|
||||
const scope = rawScope as MemoryScope;
|
||||
const entries = store.list().filter((e) => e.scope === scope);
|
||||
let deleted = 0;
|
||||
for (const e of entries) {
|
||||
try {
|
||||
if (store.delete(scope, e.name)) deleted++;
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return { info: `▸ cleared scope=${scope} — deleted ${deleted} memory file(s).` };
|
||||
}
|
||||
|
||||
// Bare `/memory` — show REASONIX.md + both MEMORY.md blocks.
|
||||
const parts: string[] = [];
|
||||
const projMem = readProjectMemory(ctx.memoryRoot);
|
||||
if (projMem) {
|
||||
const hdr = projMem.truncated
|
||||
? `▸ ${PROJECT_MEMORY_FILE}: ${projMem.path} (${projMem.originalChars.toLocaleString()} chars, truncated)`
|
||||
: `▸ ${PROJECT_MEMORY_FILE}: ${projMem.path} (${projMem.originalChars.toLocaleString()} chars)`;
|
||||
parts.push(hdr, "", projMem.content);
|
||||
}
|
||||
const globalIdx = store.loadIndex("global");
|
||||
if (globalIdx) {
|
||||
parts.push(
|
||||
"",
|
||||
`▸ global memory (${globalIdx.originalChars.toLocaleString()} chars${globalIdx.truncated ? ", truncated" : ""})`,
|
||||
"",
|
||||
globalIdx.content,
|
||||
);
|
||||
}
|
||||
const projectIdx = store.loadIndex("project");
|
||||
if (projectIdx) {
|
||||
parts.push(
|
||||
"",
|
||||
`▸ project memory (${projectIdx.originalChars.toLocaleString()} chars${projectIdx.truncated ? ", truncated" : ""})`,
|
||||
"",
|
||||
projectIdx.content,
|
||||
);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return {
|
||||
info: [
|
||||
`no memory pinned in ${ctx.memoryRoot}.`,
|
||||
"",
|
||||
"Three layers are available:",
|
||||
` 1. ${PROJECT_MEMORY_FILE} — committable team memory (in the repo).`,
|
||||
" 2. ~/.reasonix/memory/global/ — your cross-project private memory.",
|
||||
` 3. ~/.reasonix/memory/<project-hash>/ — this project's private memory.`,
|
||||
"",
|
||||
"Ask the model to `remember` something, or hand-edit files directly.",
|
||||
"Changes take effect on next /new or launch — the system prompt is hashed once per session to keep the prefix cache warm.",
|
||||
"",
|
||||
"Subcommands: /memory list | /memory show <name> | /memory forget <name> | /memory clear <scope> confirm",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
parts.push(
|
||||
"",
|
||||
"Changes take effect on next /new or launch. Subcommands: /memory list | show | forget | clear",
|
||||
);
|
||||
return { info: parts.join("\n") };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = { memory };
|
||||
@@ -0,0 +1,187 @@
|
||||
import { saveReasoningEffort } from "../../../../config.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const model: SlashHandler = (args, loop, ctx) => {
|
||||
const id = args[0];
|
||||
const known = ctx.models ?? null;
|
||||
if (!id) {
|
||||
const hint =
|
||||
known && known.length > 0
|
||||
? known.join(" | ")
|
||||
: "try deepseek-chat or deepseek-reasoner — run /models to fetch the live list";
|
||||
return { info: `usage: /model <id> (${hint})` };
|
||||
}
|
||||
loop.configure({ model: id });
|
||||
// Soft validation: if we have the live list and the id isn't in
|
||||
// it, flag a warning but still switch — DeepSeek may have just
|
||||
// released something we haven't indexed yet, and refusing would
|
||||
// be worse than a bad API error on the next call.
|
||||
if (known && known.length > 0 && !known.includes(id)) {
|
||||
return {
|
||||
info: `model → ${id} (⚠ not in the fetched catalog: ${known.join(", ")}. If this is wrong the next call will 400 — run /models to refresh.)`,
|
||||
};
|
||||
}
|
||||
return { info: `model → ${id}` };
|
||||
};
|
||||
|
||||
const models: SlashHandler = (_args, loop, ctx) => {
|
||||
const list = ctx.models ?? null;
|
||||
if (list === null) {
|
||||
ctx.refreshModels?.();
|
||||
return {
|
||||
info: "fetching /models from DeepSeek… run /models again in a moment. If it stays empty, your API key may lack permission or the network is blocked.",
|
||||
};
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return {
|
||||
info: "DeepSeek /models returned an empty list. Try /models again, or check your account status at api-docs.deepseek.com.",
|
||||
};
|
||||
}
|
||||
const current = loop.model;
|
||||
const lines = list.map((id) => (id === current ? `▸ ${id} (current)` : ` ${id}`));
|
||||
return {
|
||||
info: [
|
||||
`Available models (DeepSeek /models · ${list.length} total):`,
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"Switch with: /model <id>",
|
||||
].join("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
const harvest: SlashHandler = (args, loop) => {
|
||||
const arg = (args[0] ?? "").toLowerCase();
|
||||
const on = arg === "" ? !loop.harvestEnabled : arg === "on" || arg === "true" || arg === "1";
|
||||
loop.configure({ harvest: on });
|
||||
if (loop.harvestEnabled) {
|
||||
return {
|
||||
info: "harvest → on (Pillar-2 plan-state extraction · +1 cheap flash call per turn · opt-in only; no preset turns it on)",
|
||||
};
|
||||
}
|
||||
return { info: "harvest → off" };
|
||||
};
|
||||
|
||||
const preset: SlashHandler = (args, loop) => {
|
||||
const name = (args[0] ?? "").toLowerCase();
|
||||
// Persist the effort along with the preset change so a relaunch
|
||||
// doesn't revert to the previously-saved /effort value.
|
||||
const applyAndPersist = (effort: "high" | "max") => {
|
||||
try {
|
||||
saveReasoningEffort(effort);
|
||||
} catch {
|
||||
/* disk full / perms — runtime change still took effect */
|
||||
}
|
||||
};
|
||||
if (name === "fast" || name === "default") {
|
||||
loop.configure({
|
||||
model: "deepseek-v4-flash",
|
||||
reasoningEffort: "high",
|
||||
harvest: false,
|
||||
branch: 1,
|
||||
});
|
||||
applyAndPersist("high");
|
||||
return { info: "preset → fast (v4-flash · effort=high · cheapest)" };
|
||||
}
|
||||
if (name === "smart") {
|
||||
loop.configure({
|
||||
model: "deepseek-v4-flash",
|
||||
reasoningEffort: "max",
|
||||
harvest: false,
|
||||
branch: 1,
|
||||
});
|
||||
applyAndPersist("max");
|
||||
return { info: "preset → smart (v4-flash · effort=max · default · ~1.5× fast)" };
|
||||
}
|
||||
if (name === "max" || name === "best") {
|
||||
loop.configure({
|
||||
model: "deepseek-v4-pro",
|
||||
reasoningEffort: "max",
|
||||
harvest: false,
|
||||
branch: 1,
|
||||
});
|
||||
applyAndPersist("max");
|
||||
return {
|
||||
info: "preset → max (v4-pro · effort=max · ~12× fast · save for hard tasks, or use /pro for a single-turn bump)",
|
||||
};
|
||||
}
|
||||
return { info: "usage: /preset <fast|smart|max>" };
|
||||
};
|
||||
|
||||
const branch: SlashHandler = (args, loop) => {
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
if (raw === "" || raw === "off" || raw === "0" || raw === "1") {
|
||||
loop.configure({ branch: 1 });
|
||||
return { info: "branch → off" };
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 2) {
|
||||
return { info: "usage: /branch <N> (N>=2, or 'off')" };
|
||||
}
|
||||
if (n > 8) {
|
||||
return { info: "branch budget capped at 8 to prevent runaway cost" };
|
||||
}
|
||||
loop.configure({ branch: n });
|
||||
return {
|
||||
info: `branch → ${n} (runs ${n} parallel samples per turn · ${n}× per-turn cost · streaming disabled · manual only, no preset enables branching)`,
|
||||
};
|
||||
};
|
||||
|
||||
const effort: SlashHandler = (args, loop) => {
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
if (raw === "") {
|
||||
return {
|
||||
info: `reasoning_effort → ${loop.reasoningEffort} (use /effort high for cheaper/faster, /effort max for the agent-class default · persisted across relaunches)`,
|
||||
};
|
||||
}
|
||||
if (raw !== "high" && raw !== "max") {
|
||||
return { info: "usage: /effort <high|max>" };
|
||||
}
|
||||
loop.configure({ reasoningEffort: raw });
|
||||
// Persist so the next launch starts with this value instead of
|
||||
// reverting to the constructor default.
|
||||
try {
|
||||
saveReasoningEffort(raw);
|
||||
} catch {
|
||||
/* disk full / perms — runtime change still took effect */
|
||||
}
|
||||
return { info: `reasoning_effort → ${raw} (persisted)` };
|
||||
};
|
||||
|
||||
const pro: SlashHandler = (args, loop, ctx) => {
|
||||
const arg = (args[0] ?? "").toLowerCase();
|
||||
if (arg === "off" || arg === "cancel" || arg === "disarm") {
|
||||
if (!loop.proArmed) {
|
||||
return { info: "nothing armed — /pro with no args will arm pro for your next turn" };
|
||||
}
|
||||
if (ctx.disarmPro) ctx.disarmPro();
|
||||
else loop.disarmPro();
|
||||
return { info: "▸ /pro disarmed — next turn falls back to the current preset" };
|
||||
}
|
||||
if (arg && arg !== "on" && arg !== "arm") {
|
||||
return {
|
||||
info: "usage: /pro arm pro for the next turn (one-shot, auto-disarms after)\n /pro off cancel armed state before the next turn",
|
||||
};
|
||||
}
|
||||
if (ctx.armPro) ctx.armPro();
|
||||
else loop.armProForNextTurn();
|
||||
return {
|
||||
info: `▸ /pro armed — your NEXT message runs on ${ESCALATION_MODEL_ID} regardless of preset. Auto-disarms after one turn. Use /preset max for a persistent switch.`,
|
||||
};
|
||||
};
|
||||
|
||||
// Kept in sync with loop.ts ESCALATION_MODEL — hard-coding rather than
|
||||
// importing it because loop.ts already exports enough surface, and this
|
||||
// string is user-facing (description strings stay verbatim even if the
|
||||
// internal constant renames).
|
||||
const ESCALATION_MODEL_ID = "deepseek-v4-pro";
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
model,
|
||||
models,
|
||||
harvest,
|
||||
preset,
|
||||
branch,
|
||||
effort,
|
||||
pro,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../../../telemetry.js";
|
||||
import { countTokens } from "../../../../tokenizer.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { compactNum, formatToolList } from "../helpers.js";
|
||||
|
||||
const think: SlashHandler = (_args, loop) => {
|
||||
const raw = loop.scratch.reasoning;
|
||||
if (!raw || !raw.trim()) {
|
||||
return {
|
||||
info:
|
||||
"no reasoning cached. `/think` shows the full R1 thought for the most recent turn — " +
|
||||
"only `deepseek-reasoner` produces it, and only once the turn completes.",
|
||||
};
|
||||
}
|
||||
return { info: `↳ full thinking (${raw.length} chars):\n\n${raw.trim()}` };
|
||||
};
|
||||
|
||||
const tool: SlashHandler = (args, _loop, ctx) => {
|
||||
// EventLog truncates tool results at 400 chars for display. When the
|
||||
// user wants to check what the model actually read (e.g. to verify
|
||||
// it isn't hallucinating a file's contents), they need the full
|
||||
// text. `/tool` is the escape hatch.
|
||||
const history = ctx.toolHistory?.() ?? [];
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
info:
|
||||
"no tool calls yet in this session. `/tool` lists them once the model has actually " +
|
||||
"used a tool; `/tool N` dumps the full (untruncated) output of the Nth-most-recent.",
|
||||
};
|
||||
}
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
if (raw === "" || raw === "list" || raw === "ls") {
|
||||
return { info: formatToolList(history) };
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
return {
|
||||
info: "usage: /tool [N] (no arg → list; N=1 → most recent result in full, N=2 → previous, …)",
|
||||
};
|
||||
}
|
||||
if (n > history.length) {
|
||||
return {
|
||||
info: `only ${history.length} tool call(s) in history — asked for #${n}. Try /tool with no arg to see the list.`,
|
||||
};
|
||||
}
|
||||
const entry = history[history.length - n];
|
||||
if (!entry) {
|
||||
return { info: `could not read tool call #${n}` };
|
||||
}
|
||||
return {
|
||||
info: `↳ tool<${entry.toolName}> #${n} (${entry.text.length} chars):\n\n${entry.text}`,
|
||||
};
|
||||
};
|
||||
|
||||
const context: SlashHandler = (_args, loop) => {
|
||||
// Measure each slice of the next request locally so the user can see
|
||||
// *where* context is being spent — a much more useful number than
|
||||
// the "last turn's total" the gauge shows. Tokenization is lazy so
|
||||
// the first /context call carries the data-file load cost (~100ms);
|
||||
// subsequent calls are pure compute.
|
||||
const systemTokens = countTokens(loop.prefix.system);
|
||||
const toolsTokens = countTokens(JSON.stringify(loop.prefix.toolSpecs));
|
||||
const entries = loop.log.toMessages();
|
||||
let userTokens = 0;
|
||||
let assistantTokens = 0;
|
||||
let toolResultTokens = 0;
|
||||
let toolCallTokens = 0;
|
||||
const toolBreakdown: Array<{ name: string; tokens: number; turn: number }> = [];
|
||||
let logTurn = 0;
|
||||
for (const e of entries) {
|
||||
const content = typeof e.content === "string" ? e.content : "";
|
||||
if (e.role === "user") {
|
||||
userTokens += countTokens(content);
|
||||
logTurn += 1;
|
||||
} else if (e.role === "assistant") {
|
||||
assistantTokens += countTokens(content);
|
||||
if (Array.isArray(e.tool_calls) && e.tool_calls.length > 0) {
|
||||
toolCallTokens += countTokens(JSON.stringify(e.tool_calls));
|
||||
}
|
||||
} else if (e.role === "tool") {
|
||||
const n = countTokens(content);
|
||||
toolResultTokens += n;
|
||||
toolBreakdown.push({ name: e.name ?? "?", tokens: n, turn: logTurn });
|
||||
}
|
||||
}
|
||||
const logTokens = userTokens + assistantTokens + toolResultTokens + toolCallTokens;
|
||||
const total = systemTokens + toolsTokens + logTokens;
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const pct = (n: number) => (total > 0 ? `${Math.round((n / total) * 100)}%`.padStart(4) : " 0%");
|
||||
const row = (label: string, n: number, note = "") =>
|
||||
` ${label.padEnd(20)}${compactNum(n).padStart(8)} tokens ${pct(n)}${note ? ` ${note}` : ""}`;
|
||||
|
||||
const lines = [
|
||||
`Next-request estimate: ~${compactNum(total)} tokens of ${compactNum(ctxMax)} (${Math.round(
|
||||
(total / ctxMax) * 100,
|
||||
)}% of window)`,
|
||||
"",
|
||||
row("system prompt", systemTokens),
|
||||
row("tool specs", toolsTokens, `(${loop.prefix.toolSpecs.length} tools)`),
|
||||
row("log (all turns)", logTokens, `(${entries.length} messages)`),
|
||||
` user ${compactNum(userTokens).padStart(8)} tokens`,
|
||||
` assistant ${compactNum(assistantTokens).padStart(8)} tokens`,
|
||||
` tool-call args ${compactNum(toolCallTokens).padStart(8)} tokens`,
|
||||
` tool results ${compactNum(toolResultTokens).padStart(8)} tokens`,
|
||||
];
|
||||
|
||||
// Top 5 heaviest tool results — usually where unexpected bloat
|
||||
// lives (a big read_file, an unfiltered search_content).
|
||||
if (toolBreakdown.length > 0) {
|
||||
const top = [...toolBreakdown].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
|
||||
lines.push("");
|
||||
lines.push(`Top tool results by cost (of ${toolBreakdown.length} total):`);
|
||||
for (const t of top) {
|
||||
lines.push(
|
||||
` turn ${String(t.turn).padStart(3)} ${t.name.padEnd(22)} ${compactNum(t.tokens).padStart(8)} tokens`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Count is a local estimate (DeepSeek V3 tokenizer, pure-TS port); server prompt_tokens may add ~3-6% for chat-template role markers.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
const status: SlashHandler = (_args, loop, ctx) => {
|
||||
const branchBudget = loop.branchOptions.budget ?? 1;
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const lastPromptTokens = loop.stats.summary().lastPromptTokens;
|
||||
const ctxPct = ctxMax > 0 ? Math.round((lastPromptTokens / ctxMax) * 100) : 0;
|
||||
const ctxLine =
|
||||
lastPromptTokens > 0
|
||||
? ` ctx ${compactNum(lastPromptTokens)}/${compactNum(ctxMax)} (${ctxPct}%)`
|
||||
: " ctx no turns yet";
|
||||
const pending = ctx.pendingEditCount ?? 0;
|
||||
const sessionLine = loop.sessionName
|
||||
? ` session "${loop.sessionName}" · ${loop.log.length} messages in log (resumed ${loop.resumedMessageCount})`
|
||||
: " session (ephemeral — no persistence)";
|
||||
const mcpCount = ctx.mcpSpecs?.length ?? 0;
|
||||
const toolCount = loop.prefix.toolSpecs.length;
|
||||
const mcpLine = ` mcp ${mcpCount} server(s), ${toolCount} tool(s) in registry`;
|
||||
const pendingLine =
|
||||
pending > 0 ? ` edits ${pending} pending (/apply to commit, /discard to drop)` : "";
|
||||
const planLine = ctx.planMode ? " plan ON — writes gated (submit_plan + approval)" : "";
|
||||
const modeLine =
|
||||
ctx.editMode === "auto"
|
||||
? " mode AUTO — edits apply immediately (u to undo within 5s · Shift+Tab to flip)"
|
||||
: ctx.editMode === "review"
|
||||
? " mode review — edits queue for /apply or y (Shift+Tab to flip)"
|
||||
: "";
|
||||
const lines = [
|
||||
` model ${loop.model}`,
|
||||
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"} · effort=${loop.reasoningEffort}`,
|
||||
ctxLine,
|
||||
mcpLine,
|
||||
sessionLine,
|
||||
];
|
||||
if (pendingLine) lines.push(pendingLine);
|
||||
if (planLine) lines.push(planLine);
|
||||
if (modeLine) lines.push(modeLine);
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
const compact: SlashHandler = (args, loop) => {
|
||||
// Manual companion to the automatic 60%/80% auto-compact. Re-applies
|
||||
// token-aware truncation with a tighter cap (default 4000 tokens per
|
||||
// tool result) and rewrites the session file so the shrink persists.
|
||||
// Useful when the ctx gauge in StatsPanel goes yellow/red mid-session
|
||||
// and the user wants to keep chatting without /forget'ing everything.
|
||||
const tight = Number.parseInt(args[0] ?? "", 10);
|
||||
const cap = Number.isFinite(tight) && tight >= 100 ? tight : 4000;
|
||||
const { healedCount, tokensSaved, charsSaved } = loop.compact(cap);
|
||||
if (healedCount === 0) {
|
||||
return {
|
||||
info: `▸ nothing to compact — no tool result or tool-call args in history exceed ${cap.toLocaleString()} tokens.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
info: `▸ compacted ${healedCount} payload(s) to ${cap.toLocaleString()} tokens each (tool results + tool-call args), saved ${tokensSaved.toLocaleString()} tokens (${charsSaved.toLocaleString()} chars). Session file rewritten.`,
|
||||
};
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
think,
|
||||
reasoning: think,
|
||||
tool,
|
||||
context,
|
||||
status,
|
||||
compact,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { deleteSession, listSessions } from "../../../../session.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const sessions: SlashHandler = (_args, loop) => {
|
||||
const items = listSessions();
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
info: "no saved sessions yet — chat normally and your messages will be saved automatically",
|
||||
};
|
||||
}
|
||||
const lines = ["Saved sessions:"];
|
||||
for (const s of items) {
|
||||
const sizeKb = (s.size / 1024).toFixed(1);
|
||||
const when = s.mtime.toISOString().replace("T", " ").slice(0, 16);
|
||||
const marker = s.name === loop.sessionName ? "▸" : " ";
|
||||
lines.push(
|
||||
` ${marker} ${s.name.padEnd(22)} ${String(s.messageCount).padStart(5)} msgs ${sizeKb.padStart(7)} KB ${when}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Resume with: reasonix chat --session <name>");
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
const forget: SlashHandler = (_args, loop) => {
|
||||
if (!loop.sessionName) {
|
||||
return { info: "not in a session — nothing to forget" };
|
||||
}
|
||||
const name = loop.sessionName;
|
||||
const ok = deleteSession(name);
|
||||
return {
|
||||
info: ok
|
||||
? `▸ deleted session "${name}" — current screen still shows the conversation, but next launch starts fresh`
|
||||
: `could not delete session "${name}" (already gone?)`,
|
||||
};
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
sessions,
|
||||
forget,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { SkillStore } from "../../../../skills.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
/**
|
||||
* `/skill` family. Bare `/skill` (or `/skill list`) prints the
|
||||
* discovered skills from `<projectRoot>/.reasonix/skills` (code mode
|
||||
* only) + `~/.reasonix/skills`. `/skill show <name>` dumps one body
|
||||
* inline for reading. `/skill <name> [args...]` injects the skill body
|
||||
* as a user turn via `resubmit` — the same mechanism `/apply-plan`
|
||||
* uses — so the next model turn runs with the skill's instructions
|
||||
* fresh in the log.
|
||||
*
|
||||
* Project scope is only populated when the session has a `codeRoot`
|
||||
* (set by `reasonix code`). In plain chat mode the store reads the
|
||||
* global scope only, matching how user-memory behaves.
|
||||
*/
|
||||
const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
const store = new SkillStore({ projectRoot: ctx.codeRoot });
|
||||
const sub = (args[0] ?? "").toLowerCase();
|
||||
|
||||
if (sub === "" || sub === "list" || sub === "ls") {
|
||||
const skills = store.list();
|
||||
if (skills.length === 0) {
|
||||
const lines = ["no skills found. Reasonix reads skills from:"];
|
||||
if (store.hasProjectScope()) {
|
||||
lines.push(
|
||||
" · <project>/.reasonix/skills/<name>/SKILL.md (or <name>.md) — project scope",
|
||||
);
|
||||
}
|
||||
lines.push(" · ~/.reasonix/skills/<name>/SKILL.md (or <name>.md) — global scope");
|
||||
if (!store.hasProjectScope()) {
|
||||
lines.push(" (project scope is only active in `reasonix code`)");
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"Each file's frontmatter needs at least `name` and `description`.",
|
||||
"Invoke a skill with `/skill <name> [args]` or by asking the model to call `run_skill`.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
const lines = [`User skills (${skills.length}):`];
|
||||
for (const s of skills) {
|
||||
const scope = `(${s.scope})`.padEnd(11);
|
||||
const name = s.name.padEnd(24);
|
||||
const desc = s.description.length > 70 ? `${s.description.slice(0, 69)}…` : s.description;
|
||||
lines.push(` ${scope} ${name} ${desc}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("View body: /skill show <name> Run: /skill <name> [args]");
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
if (sub === "show" || sub === "cat") {
|
||||
const target = args[1];
|
||||
if (!target) return { info: "usage: /skill show <name>" };
|
||||
const found = store.read(target);
|
||||
if (!found) return { info: `no skill found: ${target}` };
|
||||
return {
|
||||
info: [
|
||||
`▸ ${found.name} (${found.scope})`,
|
||||
found.description ? ` ${found.description}` : "",
|
||||
` ${found.path}`,
|
||||
"",
|
||||
found.body,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
// Bare `/skill <name> [args...]` — inject the body as a user turn.
|
||||
// The first arg is the skill name; remaining args are forwarded
|
||||
// verbatim as the skill's "Arguments:" line.
|
||||
const name = args[0] ?? "";
|
||||
const found = store.read(name);
|
||||
if (!found) {
|
||||
return {
|
||||
info: `no skill found: ${name} (try /skill list)`,
|
||||
};
|
||||
}
|
||||
const extra = args.slice(1).join(" ").trim();
|
||||
const header = `# Skill: ${found.name}${found.description ? `\n> ${found.description}` : ""}`;
|
||||
const argsLine = extra ? `\n\nArguments: ${extra}` : "";
|
||||
const payload = `${header}\n\n${found.body}${argsLine}`;
|
||||
return {
|
||||
info: `▸ running skill: ${found.name}${extra ? ` — ${extra}` : ""}`,
|
||||
resubmit: payload,
|
||||
};
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
skill,
|
||||
skills: skill,
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { MemoryScope, MemoryStore } from "../../../user-memory.js";
|
||||
import type { SlashResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse a `/memory show|forget` argument. Accepts bare `<name>` or
|
||||
* `<scope>/<name>`. For bare names, tries project scope first (more
|
||||
* specific, usually what the user means) then falls back to global.
|
||||
*/
|
||||
export function resolveMemoryTarget(
|
||||
store: MemoryStore,
|
||||
raw: string,
|
||||
): { scope: MemoryScope; name: string } | null {
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const scopeRaw = raw.slice(0, slash).toLowerCase();
|
||||
const name = raw.slice(slash + 1);
|
||||
if (scopeRaw !== "global" && scopeRaw !== "project") return null;
|
||||
const scope = scopeRaw as MemoryScope;
|
||||
if (scope === "project" && !store.hasProjectScope()) return null;
|
||||
return { scope, name };
|
||||
}
|
||||
for (const scope of ["project", "global"] as MemoryScope[]) {
|
||||
if (scope === "project" && !store.hasProjectScope()) continue;
|
||||
try {
|
||||
store.read(scope, raw);
|
||||
return { scope, name: raw };
|
||||
} catch {
|
||||
/* next scope */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a section (resources / prompts) of an MCP inspection into a
|
||||
* compact "name count items" form, collapsing when unsupported.
|
||||
* Names-only — descriptions and full metadata live in
|
||||
* `reasonix mcp inspect`, which is purpose-built for the deep view.
|
||||
*/
|
||||
export function appendSection(
|
||||
lines: string[],
|
||||
label: string,
|
||||
section:
|
||||
| { supported: true; items: Array<{ name: string }> }
|
||||
| { supported: false; reason: string }
|
||||
| undefined,
|
||||
): void {
|
||||
if (!section || !section.supported) {
|
||||
lines.push(
|
||||
` ${label.trim()} ${section?.supported === false ? "(not supported)" : "(none)"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const names = section.items.map((i) => i.name);
|
||||
if (names.length === 0) {
|
||||
lines.push(` ${label.trim()} (none)`);
|
||||
return;
|
||||
}
|
||||
const head = names.slice(0, 5).join(", ");
|
||||
const more = names.length > 5 ? ` +${names.length - 5} more` : "";
|
||||
lines.push(` ${label.trim()} ${names.length} [${head}${more}]`);
|
||||
}
|
||||
|
||||
export function formatToolList(history: Array<{ toolName: string; text: string }>): string {
|
||||
const total = history.length;
|
||||
const header = `Tool calls in this session (${total}, most recent first):`;
|
||||
// Show the 10 most recent. Older ones are rarely what the user
|
||||
// wants — and the help footer tells them how to reach any entry
|
||||
// by index if they do.
|
||||
const shown = Math.min(total, 10);
|
||||
const lines: string[] = [header];
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const entry = history[total - 1 - i];
|
||||
if (!entry) continue;
|
||||
const idx = i + 1; // 1-based from most recent
|
||||
const flat = entry.text.replace(/\s+/g, " ").trim();
|
||||
const preview = flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
|
||||
const name = entry.toolName.length > 24 ? `${entry.toolName.slice(0, 23)}…` : entry.toolName;
|
||||
lines.push(
|
||||
` #${String(idx).padStart(2)} ${name.padEnd(24)} ${String(entry.text.length).padStart(6)} chars ${preview}`,
|
||||
);
|
||||
}
|
||||
if (total > shown) {
|
||||
lines.push(` … (${total - shown} earlier, reach with /tool N)`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("View full output: /tool N (N=1 → most recent)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary-K token formatter: 1234 → "1.2K", 131072 → "128K". Matches
|
||||
* DeepSeek's doc ("128K context"). Every call site here is rendering
|
||||
* token counts — if a future caller wants decimal-K for dollars or
|
||||
* similar, add a separate formatter rather than reusing this one.
|
||||
*/
|
||||
export function compactNum(n: number): string {
|
||||
if (n < 1024) return String(n);
|
||||
const k = n / 1024;
|
||||
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
|
||||
}
|
||||
|
||||
export function stripOuterQuotes(s: string): string {
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `git add -A` then `git commit -m <message>` in `rootDir`. Returns
|
||||
* a SlashResult with a human-scannable info line. We surface stderr on
|
||||
* failure so the user sees exactly what git complained about (bad
|
||||
* config, pre-commit hook rejection, nothing staged, etc.).
|
||||
*/
|
||||
export function runGitCommit(rootDir: string, message: string): SlashResult {
|
||||
const add = spawnSync("git", ["add", "-A"], { cwd: rootDir, encoding: "utf8" });
|
||||
if (add.error || add.status !== 0) {
|
||||
return { info: `git add failed (${add.status ?? "?"}):\n${gitTail(add)}` };
|
||||
}
|
||||
const commit = spawnSync("git", ["commit", "-m", message], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (commit.error || commit.status !== 0) {
|
||||
return { info: `git commit failed (${commit.status ?? "?"}):\n${gitTail(commit)}` };
|
||||
}
|
||||
const firstLine = (commit.stdout || "").split(/\r?\n/)[0] ?? "";
|
||||
return { info: `▸ committed: ${message}${firstLine ? `\n ${firstLine}` : ""}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract whatever diagnostic text is available from a spawnSync
|
||||
* result — on Windows or when cwd doesn't exist, `stderr`/`stdout` can
|
||||
* be `undefined` and the caller has only `error.message` to go on.
|
||||
*/
|
||||
export function gitTail(res: ReturnType<typeof spawnSync>): string {
|
||||
const stderr = (res.stderr as string | undefined) ?? "";
|
||||
const stdout = (res.stdout as string | undefined) ?? "";
|
||||
const body = stderr.trim() || stdout.trim();
|
||||
if (body) return body;
|
||||
if (res.error) return (res.error as Error).message;
|
||||
return "(no output from git)";
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { EditMode } from "../../../config.js";
|
||||
import type { McpClient } from "../../../mcp/client.js";
|
||||
import type { InspectionReport } from "../../../mcp/inspect.js";
|
||||
import type { JobRegistry } from "../../../tools/jobs.js";
|
||||
|
||||
export interface SlashResult {
|
||||
/** Text to display back to the user as a system/info line. */
|
||||
info?: string;
|
||||
/** Exit the app. */
|
||||
exit?: boolean;
|
||||
/** Clear the visible history. */
|
||||
clear?: boolean;
|
||||
/** Unknown command — display usage hint. */
|
||||
unknown?: boolean;
|
||||
/**
|
||||
* Re-submit this text as a user message after displaying `info`.
|
||||
* Used by `/retry` — the slash command truncates the log, then
|
||||
* asks the TUI to push the original text back through the normal
|
||||
* submit flow so a fresh turn runs.
|
||||
*/
|
||||
resubmit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra runtime context a slash handler may care about but that isn't
|
||||
* already on the loop. Kept as an optional object so tests that only
|
||||
* need loop-scoped commands can skip it, and callers only populate the
|
||||
* slots that apply to their session.
|
||||
*/
|
||||
export interface SlashContext {
|
||||
/**
|
||||
* The exact `--mcp` / config-derived spec strings that were bridged
|
||||
* into this session (one entry per server). Used by `/mcp`. Empty or
|
||||
* omitted → no MCP servers attached.
|
||||
*/
|
||||
mcpSpecs?: string[];
|
||||
/**
|
||||
* Callback for `/undo` — provided by the TUI when it's running in
|
||||
* code mode. Returns a human-readable report of what was restored.
|
||||
* Absent outside code mode → `/undo` replies "not available here".
|
||||
* Accepts slash args: `[id]`, `[id, path]`.
|
||||
*/
|
||||
codeUndo?: (args: readonly string[]) => string;
|
||||
/**
|
||||
* Callback for `/apply` — commits pending edit blocks to disk. Returns
|
||||
* a report of what landed. Absent → `/apply` replies "nothing pending"
|
||||
* or "not available outside code mode".
|
||||
*/
|
||||
codeApply?: () => string;
|
||||
/**
|
||||
* Callback for `/discard` — drops the pending edit blocks without
|
||||
* touching disk.
|
||||
*/
|
||||
codeDiscard?: () => string;
|
||||
/**
|
||||
* Callback for `/history` — returns a human-readable list of every
|
||||
* edit batch this session, newest last. Includes entry ids (for
|
||||
* `/show`) and undone status.
|
||||
*/
|
||||
codeHistory?: () => string;
|
||||
/**
|
||||
* Callback for `/show [id] [path]` — with no args, shows a per-file
|
||||
* summary of the newest non-undone entry. With id only, the entry's
|
||||
* per-file summary. With id + path, the full diff of that single
|
||||
* file in that entry.
|
||||
*/
|
||||
codeShowEdit?: (args: readonly string[]) => string;
|
||||
/**
|
||||
* Root directory passed by `reasonix code`. Enables `/commit`, which
|
||||
* runs `git add -A && git commit` in this directory. Missing → `/commit`
|
||||
* replies "only available in code mode".
|
||||
*/
|
||||
codeRoot?: string;
|
||||
/**
|
||||
* How many edit blocks are currently pending `/apply` or `/discard`.
|
||||
* Surfaced by `/status`. TUI populates it live from its pending ref;
|
||||
* omitted → treat as 0 (chat-only session).
|
||||
*/
|
||||
pendingEditCount?: number;
|
||||
/**
|
||||
* Callback returning every tool result seen this session in
|
||||
* chronological order (oldest first). Powers `/tool [N]` for
|
||||
* inspecting the full untruncated output that `EventLog` clips at
|
||||
* 400 chars for display. Absent → `/tool` replies "not available".
|
||||
*/
|
||||
toolHistory?: () => Array<{ toolName: string; text: string }>;
|
||||
/**
|
||||
* Pre-captured inspection reports for each connected MCP server.
|
||||
* Populated once at chat startup (chat.tsx) so `/mcp` can render
|
||||
* tools + resources + prompts synchronously without needing async
|
||||
* handler support.
|
||||
*/
|
||||
mcpServers?: McpServerSummary[];
|
||||
/**
|
||||
* Directory `/memory` should resolve `REASONIX.md` from. In code
|
||||
* mode this is the rootDir the filesystem tools are pinned to; in
|
||||
* plain chat this is `process.cwd()` at launch time. Absent → the
|
||||
* TUI is running in some non-cwd context (tests) and `/memory`
|
||||
* replies "root unknown" instead of silently reading a different dir.
|
||||
*/
|
||||
memoryRoot?: string;
|
||||
/**
|
||||
* Current plan-mode state, surfaced in `/status` and toggled by
|
||||
* `/plan`. Present iff the session is a `reasonix code` run — chat
|
||||
* mode doesn't have plan mode.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* Current edit-gate mode (`review` or `auto`). Surfaced in `/status`,
|
||||
* toggled by `/mode`, also flipped by the Shift+Tab keybind in the
|
||||
* TUI. Absent → not in `reasonix code`.
|
||||
*/
|
||||
editMode?: EditMode;
|
||||
/**
|
||||
* Set the edit-gate mode. Callback rather than raw state so App can
|
||||
* also persist the choice to config and echo the change in historical.
|
||||
*/
|
||||
setEditMode?: (mode: EditMode) => void;
|
||||
/**
|
||||
* Background-process registry backing /jobs / /kill / /logs. Present
|
||||
* iff the session is a `reasonix code` run. Slash handlers expect
|
||||
* synchronous info strings, but stop_job is async — we return a
|
||||
* "stopping, watch /jobs" info and let the registry do its thing.
|
||||
*/
|
||||
jobs?: JobRegistry;
|
||||
/**
|
||||
* Async-late append to the historical log. /kill uses this to
|
||||
* surface "job N stopped" the moment stop() actually resolves,
|
||||
* rather than leaving the user to poll /jobs themselves.
|
||||
*/
|
||||
postInfo?: (text: string) => void;
|
||||
/**
|
||||
* Callback the `/plan` slash uses to flip plan mode on/off. Also
|
||||
* mirrors the state to the underlying ToolRegistry so dispatch
|
||||
* enforcement follows. Absent → `/plan` replies "only available in
|
||||
* code mode".
|
||||
*/
|
||||
setPlanMode?: (on: boolean) => void;
|
||||
/**
|
||||
* Callback that clears a pending-plan picker state. Called by
|
||||
* `/apply-plan` so that when the user force-approves, the picker
|
||||
* dismisses without also firing its own approval synthetic (the
|
||||
* slash returns its own `resubmit` instead). Safe to call with no
|
||||
* pending plan.
|
||||
*/
|
||||
clearPendingPlan?: () => void;
|
||||
/**
|
||||
* Re-load `~/.reasonix/settings.json` + `<project>/.reasonix/settings.json`
|
||||
* and update both the App's hook state and the loop's mutable hook
|
||||
* list. Returns the new hook count so the slash can echo a sane
|
||||
* confirmation. Absent → `/hooks reload` replies "not available".
|
||||
*/
|
||||
reloadHooks?: () => number;
|
||||
/**
|
||||
* Latest published version if App's background registry check
|
||||
* has completed, `null` otherwise (still in flight OR offline).
|
||||
* Drives `/update` — the slash shows whatever the async check
|
||||
* already resolved, so the command is fully synchronous.
|
||||
*/
|
||||
latestVersion?: string | null;
|
||||
/**
|
||||
* Fire-and-forget: kick off a fresh registry fetch. `/update`
|
||||
* calls this whenever it encounters `latestVersion === null`
|
||||
* so the user can rerun the slash a few seconds later and see
|
||||
* a concrete answer. Absent → the slash just reports "pending"
|
||||
* with no retry path.
|
||||
*/
|
||||
refreshLatestVersion?: () => void;
|
||||
/**
|
||||
* Model catalog fetched from DeepSeek's `/models` endpoint at App
|
||||
* mount. `null` → still in flight or the call failed (auth / offline);
|
||||
* `[]` → the API answered with zero entries. `/models` uses this to
|
||||
* render the list, and `/model <id>` uses it for soft validation
|
||||
* (warn-only — we still switch even on unknown ids since the list
|
||||
* can lag a newly-released model).
|
||||
*/
|
||||
models?: string[] | null;
|
||||
/**
|
||||
* Fire-and-forget refresh for the models list. Lets `/models` retry
|
||||
* after a flaky first fetch without needing async slash support.
|
||||
*/
|
||||
refreshModels?: () => void;
|
||||
/**
|
||||
* Arm pro for the next turn. Called by `/pro`. The TUI wires this
|
||||
* to both `loop.armProForNextTurn()` and its React mirror state so
|
||||
* the StatsPanel badge flips immediately. Absent → the handler
|
||||
* calls `loop.armProForNextTurn()` directly and the badge updates
|
||||
* on the next render tick (slightly laggy but still correct).
|
||||
*/
|
||||
armPro?: () => void;
|
||||
/** Cancel a pending /pro arming. Mirrors `armPro` semantics. */
|
||||
disarmPro?: () => void;
|
||||
}
|
||||
|
||||
export interface McpServerSummary {
|
||||
/** Short label shown in the `/mcp` output (server namespace or "anon"). */
|
||||
label: string;
|
||||
/** Original --mcp spec string. */
|
||||
spec: string;
|
||||
/** Count of tools bridged into the Reasonix registry from this server. */
|
||||
toolCount: number;
|
||||
/** Full inspection snapshot — used for the resources + prompts sections. */
|
||||
report: InspectionReport;
|
||||
/**
|
||||
* Live MCP client, kept so `/resource` and `/prompt` can call
|
||||
* `readResource` / `getPrompt` against this server. Omitted by
|
||||
* callers that only build the summary for display; those slashes
|
||||
* fall back to "not available" when the client is missing.
|
||||
*/
|
||||
client?: McpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash command registry. Drives `/help`, the on-type suggestion
|
||||
* popup (`SlashSuggestions`), and auto-complete. Kept as data rather
|
||||
* than derived from the `handleSlash` switch so summaries can be
|
||||
* user-facing rather than code comments.
|
||||
*
|
||||
* `contextual` gates commands that only make sense in certain modes:
|
||||
* - `"code"` — only show when the TUI is running `reasonix code`
|
||||
* - absent → always shown
|
||||
*/
|
||||
export interface SlashCommandSpec {
|
||||
cmd: string;
|
||||
summary: string;
|
||||
contextual?: "code";
|
||||
/** If the command takes args, hint text shown after the name. */
|
||||
argsHint?: string;
|
||||
/**
|
||||
* How the first argument position should autocomplete. Shapes the
|
||||
* picker that appears below the prompt once the user types `/<cmd>`
|
||||
* + space:
|
||||
* - `"models"` → DeepSeek model-id list fetched at startup.
|
||||
* - `"mcp-resources"` → live URIs aggregated across connected MCP servers.
|
||||
* - `"mcp-prompts"` → live prompt names aggregated across MCP servers.
|
||||
* - `string[]` → small enum of literal values (e.g. `["on", "off"]`).
|
||||
* - omitted → no picker; a persistent usage hint shows the
|
||||
* argsHint + summary so the user knows what to type.
|
||||
*
|
||||
* File-path completion is deliberately NOT offered here. Users who
|
||||
* want to reference a file in a prompt use `@path/to/file` (0.5.5);
|
||||
* the `@` picker already surfaces files with mtime + recently-used
|
||||
* ranking. Adding a second file-picker surface for slash commands
|
||||
* would split the UX without adding leverage.
|
||||
*/
|
||||
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape describing what the prompt buffer is asking for AFTER the
|
||||
* user has committed to a slash command (`/<cmd>`) and started
|
||||
* typing its first argument. Consumed by the TUI to drive an
|
||||
* argument-level picker.
|
||||
*/
|
||||
export interface SlashArgContext {
|
||||
/** The command spec (looked up by name from SLASH_COMMANDS). */
|
||||
spec: SlashCommandSpec;
|
||||
/** The partial first-argument text, possibly empty. */
|
||||
partial: string;
|
||||
/**
|
||||
* Buffer offset where `partial` begins. Used by the TUI to splice
|
||||
* a picked completion back in at the right position.
|
||||
*/
|
||||
partialOffset: number;
|
||||
/**
|
||||
* Classification of what the caller should show:
|
||||
* - `"picker"` → an interactive picker (file / enum / models). The
|
||||
* caller uses `spec.argCompleter` to pick the data source and
|
||||
* filters against `partial`.
|
||||
* - `"hint"` → past the completable position (additional args or
|
||||
* no completer declared). Caller shows a dim usage hint only.
|
||||
*/
|
||||
kind: "picker" | "hint";
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type FileWithStats,
|
||||
detectAtPicker,
|
||||
listFilesWithStatsSync,
|
||||
rankPickerCandidates,
|
||||
} from "../../at-mentions.js";
|
||||
import {
|
||||
type McpServerSummary,
|
||||
type SlashArgContext,
|
||||
type SlashCommandSpec,
|
||||
detectSlashArgContext,
|
||||
suggestSlashCommands,
|
||||
} from "./slash.js";
|
||||
|
||||
export interface UseCompletionPickersParams {
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
codeMode: { rootDir: string } | undefined;
|
||||
models: string[] | null;
|
||||
mcpServers: McpServerSummary[] | undefined;
|
||||
}
|
||||
|
||||
export interface UseCompletionPickersResult {
|
||||
// ── slash-name picker ──
|
||||
slashMatches: SlashCommandSpec[] | null;
|
||||
slashSelected: number;
|
||||
setSlashSelected: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
// ── @-mention picker ──
|
||||
atPicker: ReturnType<typeof detectAtPicker>;
|
||||
atMatches: readonly string[] | null;
|
||||
atSelected: number;
|
||||
setAtSelected: React.Dispatch<React.SetStateAction<number>>;
|
||||
pickAtMention: (chosenPath: string) => void;
|
||||
recordRecentFile: (path: string) => void;
|
||||
|
||||
// ── slash-arg picker ──
|
||||
slashArgContext: SlashArgContext | null;
|
||||
slashArgMatches: readonly string[] | null;
|
||||
slashArgSelected: number;
|
||||
setSlashArgSelected: React.Dispatch<React.SetStateAction<number>>;
|
||||
pickSlashArg: (chosen: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three mutually-exclusive input-prefix pickers share enough
|
||||
* structure (matches list + highlighted index + reset-on-shrink effect
|
||||
* + commit-back-into-buffer callback) that they live together here.
|
||||
* Priority in the parent useInput handler:
|
||||
* atMatches > slashArgMatches > slashMatches > prompt history
|
||||
* detection already disambiguates by construction — the slash-name
|
||||
* picker fires only when the buffer has no space; the slash-arg picker
|
||||
* only after a space; the @-picker only outside slash mode.
|
||||
*/
|
||||
export function useCompletionPickers({
|
||||
input,
|
||||
setInput,
|
||||
codeMode,
|
||||
models,
|
||||
mcpServers,
|
||||
}: UseCompletionPickersParams): UseCompletionPickersResult {
|
||||
// ── slash-name picker ──
|
||||
const [slashSelected, setSlashSelected] = useState(0);
|
||||
const slashMatches = useMemo(() => {
|
||||
if (!input.startsWith("/") || input.includes(" ")) return null;
|
||||
return suggestSlashCommands(input.slice(1), !!codeMode);
|
||||
}, [input, codeMode]);
|
||||
useEffect(() => {
|
||||
setSlashSelected((prev) => {
|
||||
if (!slashMatches || slashMatches.length === 0) return 0;
|
||||
if (prev >= slashMatches.length) return slashMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [slashMatches]);
|
||||
|
||||
// ── @-mention picker ──
|
||||
const [atSelected, setAtSelected] = useState(0);
|
||||
// Walk the code root ONCE on mount, collecting mtime alongside path
|
||||
// so the picker can surface recently-edited files first. Files
|
||||
// created mid-session via tool edits won't appear until restart —
|
||||
// rare, and the user can always type the full path.
|
||||
const atFiles = useMemo<readonly FileWithStats[]>(() => {
|
||||
if (!codeMode?.rootDir) return [];
|
||||
try {
|
||||
return listFilesWithStatsSync(codeMode.rootDir, { maxResults: 500 });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [codeMode?.rootDir]);
|
||||
// LRU of files touched by recent tool calls. Seeds the picker with
|
||||
// "stuff I just looked at" at the top, so a user typing `@` in the
|
||||
// same file they were just discussing gets it instantly.
|
||||
const recentFilesRef = useRef<string[]>([]);
|
||||
const recordRecentFile = useCallback((p: string) => {
|
||||
const list = recentFilesRef.current;
|
||||
const i = list.indexOf(p);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
list.unshift(p);
|
||||
if (list.length > 20) list.length = 20;
|
||||
}, []);
|
||||
const atPicker = useMemo(() => {
|
||||
if (!codeMode?.rootDir) return null;
|
||||
// Slash prefix wins — avoids the picker confusingly surfacing on
|
||||
// `/@wat`-style edge inputs.
|
||||
if (slashMatches !== null) return null;
|
||||
return detectAtPicker(input);
|
||||
}, [codeMode?.rootDir, input, slashMatches]);
|
||||
const atMatches = useMemo<readonly string[] | null>(() => {
|
||||
if (!atPicker) return null;
|
||||
return rankPickerCandidates(atFiles, atPicker.query, {
|
||||
limit: 40,
|
||||
recentlyUsed: recentFilesRef.current,
|
||||
});
|
||||
}, [atPicker, atFiles]);
|
||||
useEffect(() => {
|
||||
setAtSelected((prev) => {
|
||||
if (!atMatches || atMatches.length === 0) return 0;
|
||||
if (prev >= atMatches.length) return atMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [atMatches]);
|
||||
const pickAtMention = useCallback(
|
||||
(chosenPath: string) => {
|
||||
if (!atPicker) return;
|
||||
const before = input.slice(0, atPicker.atOffset);
|
||||
setInput(`${before}@${chosenPath} `);
|
||||
},
|
||||
[atPicker, input, setInput],
|
||||
);
|
||||
|
||||
// ── slash-arg picker ──
|
||||
const [slashArgSelected, setSlashArgSelected] = useState(0);
|
||||
const slashArgContext = useMemo<SlashArgContext | null>(() => {
|
||||
if (!input.startsWith("/")) return null;
|
||||
if (slashMatches !== null) return null;
|
||||
return detectSlashArgContext(input, !!codeMode);
|
||||
}, [input, slashMatches, codeMode]);
|
||||
const slashArgMatches = useMemo<readonly string[] | null>(() => {
|
||||
if (!slashArgContext || slashArgContext.kind !== "picker") return null;
|
||||
const completer = slashArgContext.spec.argCompleter;
|
||||
const partial = slashArgContext.partial;
|
||||
const needle = partial.toLowerCase();
|
||||
// Once the partial is an EXACT match for a valid completion, hide
|
||||
// the picker so Enter submits the command instead of re-picking
|
||||
// the same value in an infinite loop. Case-insensitive — users
|
||||
// may type `/preset FAST` and still mean the same thing.
|
||||
if (Array.isArray(completer)) {
|
||||
if (partial && completer.some((v) => v.toLowerCase() === needle)) return null;
|
||||
if (!partial) return completer.slice();
|
||||
return completer.filter((v) => v.toLowerCase().startsWith(needle));
|
||||
}
|
||||
if (completer === "models") {
|
||||
const all = models ?? [];
|
||||
if (partial && all.some((m) => m.toLowerCase() === needle)) return null;
|
||||
if (!partial) return all.slice(0, 40);
|
||||
return all.filter((m) => m.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
if (completer === "mcp-resources") {
|
||||
// Aggregate URIs across every server's cached inspection.
|
||||
const uris: string[] = [];
|
||||
const servers = mcpServers ?? [];
|
||||
for (const s of servers) {
|
||||
if (!s.report.resources.supported) continue;
|
||||
for (const r of s.report.resources.items) uris.push(r.uri);
|
||||
}
|
||||
if (partial && uris.some((u) => u.toLowerCase() === needle)) return null;
|
||||
if (!partial) return uris.slice(0, 40);
|
||||
return uris.filter((u) => u.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
if (completer === "mcp-prompts") {
|
||||
const names: string[] = [];
|
||||
const servers = mcpServers ?? [];
|
||||
for (const s of servers) {
|
||||
if (!s.report.prompts.supported) continue;
|
||||
for (const p of s.report.prompts.items) names.push(p.name);
|
||||
}
|
||||
if (partial && names.some((n) => n.toLowerCase() === needle)) return null;
|
||||
if (!partial) return names.slice(0, 40);
|
||||
return names.filter((n) => n.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
return null;
|
||||
}, [slashArgContext, models, mcpServers]);
|
||||
useEffect(() => {
|
||||
setSlashArgSelected((prev) => {
|
||||
if (!slashArgMatches || slashArgMatches.length === 0) return 0;
|
||||
if (prev >= slashArgMatches.length) return slashArgMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [slashArgMatches]);
|
||||
const pickSlashArg = useCallback(
|
||||
(chosen: string) => {
|
||||
if (!slashArgContext) return;
|
||||
const before = input.slice(0, slashArgContext.partialOffset);
|
||||
// No trailing space — enum picks take no further args, so the
|
||||
// user presses Enter once more to run the command.
|
||||
setInput(`${before}${chosen}`);
|
||||
},
|
||||
[slashArgContext, input, setInput],
|
||||
);
|
||||
|
||||
return {
|
||||
slashMatches,
|
||||
slashSelected,
|
||||
setSlashSelected,
|
||||
atPicker,
|
||||
atMatches,
|
||||
atSelected,
|
||||
setAtSelected,
|
||||
pickAtMention,
|
||||
recordRecentFile,
|
||||
slashArgContext,
|
||||
slashArgMatches,
|
||||
slashArgSelected,
|
||||
setSlashArgSelected,
|
||||
pickSlashArg,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { formatAllBlockDiffs } from "../../code/diff-preview.js";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
restoreSnapshots,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import {
|
||||
type EditHistoryEntry,
|
||||
entryStatus,
|
||||
formatUndoRows,
|
||||
isEntryFullyUndone,
|
||||
} from "./edit-history.js";
|
||||
|
||||
export interface UndoBannerState {
|
||||
results: ApplyResult[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface UseEditHistoryResult {
|
||||
/** Post-auto-apply banner state — rendered at the bottom for 5s. */
|
||||
undoBanner: UndoBannerState | null;
|
||||
/**
|
||||
* Append a batch of successfully-applied edits into the session's
|
||||
* edit history. If the current turn already has an open entry,
|
||||
* append into it (first-wins-per-path so `/undo` restores the
|
||||
* pre-turn state, not an intermediate half-edit). Otherwise open a
|
||||
* new entry.
|
||||
*/
|
||||
recordEdit: (
|
||||
source: string,
|
||||
blocks: readonly EditBlock[],
|
||||
results: readonly ApplyResult[],
|
||||
snaps: readonly EditSnapshot[],
|
||||
) => void;
|
||||
/**
|
||||
* Show the post-auto-apply undo banner for 5 seconds. Overwrites any
|
||||
* prior banner and replaces the auto-dismiss timer so multiple
|
||||
* edits inside a single turn don't prematurely expire the window.
|
||||
*/
|
||||
armUndoBanner: (results: ApplyResult[]) => void;
|
||||
/**
|
||||
* `/undo` — revert applied edits.
|
||||
* `/undo` → newest non-fully-undone entry, all its
|
||||
* remaining (non-undone) files.
|
||||
* `/undo <id>` → that specific entry's remaining files.
|
||||
* `/undo <id> <path>` → just that one file in that entry.
|
||||
* Works as long as the session is alive; no 5-second banner limit.
|
||||
*/
|
||||
codeUndo: (args?: readonly string[]) => string;
|
||||
/**
|
||||
* `/history` — list every edit batch this session. Each entry's id
|
||||
* is the token for `/show <id>` and the target of per-batch undo.
|
||||
*/
|
||||
codeHistory: () => string;
|
||||
/**
|
||||
* `/show` — inspect a stored edit batch.
|
||||
* `/show` → newest non-fully-undone entry, per-file summary.
|
||||
* `/show <id>` → that entry's per-file summary.
|
||||
* `/show <id> <path>` → full diff of one file in that entry.
|
||||
*/
|
||||
codeShowEdit: (args?: readonly string[]) => string;
|
||||
/**
|
||||
* Seal the in-progress entry so the next edit opens a fresh one.
|
||||
* Called at handleSubmit start — prior turns stay intact for
|
||||
* `/history` and `/undo` to walk through independently.
|
||||
*/
|
||||
sealCurrentEntry: () => void;
|
||||
/**
|
||||
* True when there's at least one history entry that isn't fully
|
||||
* undone yet. Used by the `u` keybind and the bottom ModeStatusBar.
|
||||
* Reads the ref fresh — callers must re-read each time.
|
||||
*/
|
||||
hasUndoable: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-scoped edit history and undo machinery for `reasonix code`.
|
||||
* Keeps the history ref + snapshot bookkeeping out of App.tsx so the
|
||||
* parent only sees a small API: append, banner, three slash handlers,
|
||||
* a turn sealer, and an undoable probe.
|
||||
*
|
||||
* `codeMode` is optional because the hook is always mounted but its
|
||||
* handlers turn into no-ops when code mode isn't active.
|
||||
*/
|
||||
export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEditHistoryResult {
|
||||
const editHistory = useRef<EditHistoryEntry[]>([]);
|
||||
const nextHistoryId = useRef(1);
|
||||
const currentTurnEntry = useRef<EditHistoryEntry | null>(null);
|
||||
const [undoBanner, setUndoBanner] = useState<UndoBannerState | null>(null);
|
||||
const undoTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const recordEdit = useCallback<UseEditHistoryResult["recordEdit"]>(
|
||||
(source, blocks, results, snaps) => {
|
||||
if (snaps.length === 0) return;
|
||||
let entry = currentTurnEntry.current;
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: nextHistoryId.current++,
|
||||
at: Date.now(),
|
||||
source,
|
||||
blocks: [],
|
||||
results: [],
|
||||
snapshots: [],
|
||||
undoneFiles: new Set<string>(),
|
||||
};
|
||||
currentTurnEntry.current = entry;
|
||||
editHistory.current.push(entry);
|
||||
}
|
||||
entry.blocks.push(...blocks);
|
||||
entry.results.push(...results);
|
||||
const seen = new Set(entry.snapshots.map((s) => s.path));
|
||||
for (const s of snaps) {
|
||||
if (!seen.has(s.path)) entry.snapshots.push(s);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const armUndoBanner = useCallback<UseEditHistoryResult["armUndoBanner"]>((results) => {
|
||||
setUndoBanner({ results, expiresAt: Date.now() + 5000 });
|
||||
if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
|
||||
undoTimeoutRef.current = setTimeout(() => {
|
||||
setUndoBanner(null);
|
||||
undoTimeoutRef.current = null;
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const codeUndo = useCallback<UseEditHistoryResult["codeUndo"]>(
|
||||
(args = []) => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const root = codeMode.rootDir;
|
||||
|
||||
const revert = (entry: EditHistoryEntry, paths: readonly string[]): string => {
|
||||
const subset = entry.snapshots.filter((s) => paths.includes(s.path));
|
||||
if (subset.length === 0) {
|
||||
return `batch #${entry.id}: nothing to undo (already restored or path not in batch)`;
|
||||
}
|
||||
const results = restoreSnapshots(subset, root);
|
||||
for (const s of subset) entry.undoneFiles.add(s.path);
|
||||
if (currentTurnEntry.current === entry && isEntryFullyUndone(entry)) {
|
||||
currentTurnEntry.current = null;
|
||||
}
|
||||
if (undoTimeoutRef.current) {
|
||||
clearTimeout(undoTimeoutRef.current);
|
||||
undoTimeoutRef.current = null;
|
||||
}
|
||||
setUndoBanner(null);
|
||||
const when = new Date(entry.at).toISOString().replace("T", " ").slice(11, 19);
|
||||
const scope = subset.length === 1 ? subset[0]!.path : `${subset.length} file(s)`;
|
||||
const header = `▸ undo: reverted ${scope} from batch #${entry.id} (${when})`;
|
||||
return [header, ...formatUndoRows(results)].join("\n");
|
||||
};
|
||||
|
||||
const idArg = args[0];
|
||||
const pathArg = args[1];
|
||||
|
||||
if (!idArg) {
|
||||
for (let i = editHistory.current.length - 1; i >= 0; i--) {
|
||||
const e = editHistory.current[i]!;
|
||||
if (isEntryFullyUndone(e)) continue;
|
||||
const remaining = e.snapshots.map((s) => s.path).filter((p) => !e.undoneFiles.has(p));
|
||||
return revert(e, remaining);
|
||||
}
|
||||
return "nothing to undo — every batch in the session history is already undone";
|
||||
}
|
||||
|
||||
const id = Number.parseInt(idArg, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return "usage: /undo [id] [path] (omit id for newest; id from /history; path from /show <id>)";
|
||||
}
|
||||
const entry = editHistory.current.find((e) => e.id === id);
|
||||
if (!entry) return `no edit #${id} — run /history to see valid ids`;
|
||||
|
||||
if (!pathArg) {
|
||||
const remaining = entry.snapshots
|
||||
.map((s) => s.path)
|
||||
.filter((p) => !entry.undoneFiles.has(p));
|
||||
if (remaining.length === 0) return `batch #${id} is already fully undone`;
|
||||
return revert(entry, remaining);
|
||||
}
|
||||
|
||||
const snap = entry.snapshots.find((s) => s.path === pathArg);
|
||||
if (!snap) {
|
||||
const files = [...new Set(entry.blocks.map((b) => b.path))];
|
||||
return `batch #${id} doesn't include "${pathArg}" — files in this batch: ${files.join(", ")}`;
|
||||
}
|
||||
if (entry.undoneFiles.has(pathArg)) {
|
||||
return `${pathArg} in batch #${id} is already undone`;
|
||||
}
|
||||
return revert(entry, [pathArg]);
|
||||
},
|
||||
[codeMode],
|
||||
);
|
||||
|
||||
const codeHistory = useCallback<UseEditHistoryResult["codeHistory"]>(() => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const entries = editHistory.current;
|
||||
if (entries.length === 0) return "no edits recorded this session yet";
|
||||
const lines = ["Edit history (oldest first):"];
|
||||
for (const e of entries) {
|
||||
const when = new Date(e.at).toISOString().replace("T", " ").slice(11, 19);
|
||||
const files = new Set(e.blocks.map((b) => b.path));
|
||||
const fileList = [...files].join(", ");
|
||||
const fileSummary = fileList.length > 60 ? `${fileList.slice(0, 60)}…` : fileList;
|
||||
const status = entryStatus(e);
|
||||
const statusText =
|
||||
status === "applied" ? "applied" : status === "PARTIAL" ? "PARTIAL" : "UNDONE ";
|
||||
lines.push(
|
||||
` #${String(e.id).padStart(3)} ${when} ${statusText} ${e.source.padEnd(12)} ${files.size} file · ${e.blocks.length} block ${fileSummary}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"/show <id> → per-file summary · /show <id> <path> → full diff of one file",
|
||||
);
|
||||
lines.push(
|
||||
"/undo → newest non-undone · /undo <id> [path] → target a specific batch or file",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}, [codeMode]);
|
||||
|
||||
const codeShowEdit = useCallback<UseEditHistoryResult["codeShowEdit"]>(
|
||||
(args = []) => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const entries = editHistory.current;
|
||||
if (entries.length === 0) return "no edits recorded this session — /history is empty";
|
||||
|
||||
const idArg = args[0];
|
||||
const pathArg = args[1];
|
||||
|
||||
let entry: EditHistoryEntry | undefined;
|
||||
if (!idArg) {
|
||||
entry =
|
||||
[...entries].reverse().find((e) => !isEntryFullyUndone(e)) ?? entries[entries.length - 1];
|
||||
} else {
|
||||
const id = Number.parseInt(idArg, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return "usage: /show [id] [path] (omit id for newest; path from the per-file summary)";
|
||||
}
|
||||
entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return `no edit #${id} — run /history to see valid ids`;
|
||||
}
|
||||
if (!entry) return "unexpected: history lookup failed";
|
||||
|
||||
if (pathArg) {
|
||||
const fileBlocks = entry.blocks.filter((b) => b.path === pathArg);
|
||||
if (fileBlocks.length === 0) {
|
||||
const files = [...new Set(entry.blocks.map((b) => b.path))];
|
||||
return `batch #${entry.id} doesn't include "${pathArg}" — files in this batch: ${files.join(", ")}`;
|
||||
}
|
||||
const when = new Date(entry.at).toISOString().replace("T", " ").slice(11, 19);
|
||||
const state = entry.undoneFiles.has(pathArg) ? "UNDONE" : "applied";
|
||||
const header = `▸ edit #${entry.id} · ${when} · ${pathArg} · ${state} · ${fileBlocks.length} block(s)`;
|
||||
const diff = formatAllBlockDiffs(fileBlocks, { maxLines: 60, contextLines: 2 });
|
||||
const footer = entry.undoneFiles.has(pathArg)
|
||||
? "(already reverted — /history shows the batch-level status)"
|
||||
: `/undo ${entry.id} ${pathArg} → revert just this file`;
|
||||
return [header, ...diff, "", footer].join("\n");
|
||||
}
|
||||
|
||||
const when = new Date(entry.at).toISOString().replace("T", " ").slice(11, 19);
|
||||
const files = [...new Set(entry.blocks.map((b) => b.path))];
|
||||
const status = entryStatus(entry);
|
||||
const header = `▸ edit #${entry.id} · ${when} · ${entry.source} · ${status} · ${files.length} file(s)`;
|
||||
const countLines = (s: string) => (s.length === 0 ? 0 : (s.match(/\n/g)?.length ?? 0) + 1);
|
||||
const fileLines = files.map((path) => {
|
||||
const fileBlocks = entry!.blocks.filter((b) => b.path === path);
|
||||
let removed = 0;
|
||||
let added = 0;
|
||||
for (const b of fileBlocks) {
|
||||
removed += countLines(b.search);
|
||||
added += countLines(b.replace);
|
||||
}
|
||||
const state = entry!.undoneFiles.has(path) ? "UNDONE" : "applied";
|
||||
return ` ${state.padEnd(7)} -${String(removed).padStart(3)}/+${String(added).padStart(3)} ${path} (${fileBlocks.length} block${fileBlocks.length === 1 ? "" : "s"})`;
|
||||
});
|
||||
return [
|
||||
header,
|
||||
...fileLines,
|
||||
"",
|
||||
`/show ${entry.id} <path> → full diff of one file`,
|
||||
`/undo ${entry.id} <path> → revert just that file · /undo ${entry.id} → revert whole batch`,
|
||||
].join("\n");
|
||||
},
|
||||
[codeMode],
|
||||
);
|
||||
|
||||
const sealCurrentEntry = useCallback(() => {
|
||||
currentTurnEntry.current = null;
|
||||
}, []);
|
||||
|
||||
const hasUndoable = useCallback(
|
||||
() => editHistory.current.some((e) => !isEntryFullyUndone(e)),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
undoBanner,
|
||||
recordEdit,
|
||||
armUndoBanner,
|
||||
codeUndo,
|
||||
codeHistory,
|
||||
codeShowEdit,
|
||||
sealCurrentEntry,
|
||||
hasUndoable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import { VERSION, compareVersions, getLatestVersion } from "../../version.js";
|
||||
|
||||
export interface Balance {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UseSessionInfoResult {
|
||||
balance: Balance | null;
|
||||
models: string[] | null;
|
||||
latestVersion: string | null;
|
||||
/** Strictly-newer version string (for the header badge) — else `null`. */
|
||||
updateAvailable: string | null;
|
||||
refreshBalance: () => void;
|
||||
refreshModels: () => void;
|
||||
refreshLatestVersion: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient session-level data fetched from DeepSeek + the npm registry
|
||||
* at mount, refreshed either implicitly (balance after each turn) or
|
||||
* on demand (`/update`, `/models`).
|
||||
*
|
||||
* All three are best-effort — a `null` value means "not loaded yet or
|
||||
* the endpoint failed." The StatsPanel hides the cell in that case.
|
||||
*/
|
||||
export function useSessionInfo(loop: CacheFirstLoop): UseSessionInfoResult {
|
||||
const [balance, setBalance] = useState<Balance | null>(null);
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
// Fetch balance on mount. Non-blocking — the session works without
|
||||
// it; `null` hides the cell. handleSubmit calls refreshBalance in
|
||||
// its finally so the number tracks actual spend rather than
|
||||
// freezing at mount-time.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const bal = await loop.client.getBalance().catch(() => null);
|
||||
if (cancelled || !bal || !bal.balance_infos.length) return;
|
||||
const primary = bal.balance_infos[0]!;
|
||||
setBalance({ currency: primary.currency, total: Number(primary.total_balance) });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loop]);
|
||||
|
||||
// Fetch the model catalog from DeepSeek once. Silent degrade on
|
||||
// failure (stays null), so `/models` can tell "still loading /
|
||||
// offline" apart from "loaded, here's the list."
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const list = await loop.client.listModels().catch(() => null);
|
||||
if (cancelled || !list) return;
|
||||
setModels(list.data.map((m) => m.id));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loop]);
|
||||
|
||||
// Background registry check — 24h disk cache absorbs repeated
|
||||
// launches, timeout bounded so a flaky network doesn't delay the
|
||||
// notification. `null` on failure (silent). We store the raw version
|
||||
// regardless of whether it's newer; the header badge's newer-only
|
||||
// check happens at the `updateAvailable` derivation below.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const latest = await getLatestVersion();
|
||||
if (cancelled || !latest) return;
|
||||
setLatestVersion(latest);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateAvailable =
|
||||
latestVersion && compareVersions(VERSION, latestVersion) < 0 ? latestVersion : null;
|
||||
|
||||
const refreshBalance = useCallback(() => {
|
||||
void (async () => {
|
||||
const bal = await loop.client.getBalance().catch(() => null);
|
||||
if (bal?.balance_infos.length) {
|
||||
const p = bal.balance_infos[0]!;
|
||||
setBalance({ currency: p.currency, total: Number(p.total_balance) });
|
||||
}
|
||||
})();
|
||||
}, [loop]);
|
||||
|
||||
const refreshModels = useCallback(() => {
|
||||
void (async () => {
|
||||
const list = await loop.client.listModels().catch(() => null);
|
||||
if (list) setModels(list.data.map((m) => m.id));
|
||||
})();
|
||||
}, [loop]);
|
||||
|
||||
const refreshLatestVersion = useCallback(() => {
|
||||
void (async () => {
|
||||
const fresh = await getLatestVersion({ force: true });
|
||||
if (fresh) setLatestVersion(fresh);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
balance,
|
||||
models,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
refreshBalance,
|
||||
refreshModels,
|
||||
refreshLatestVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SubagentEvent, SubagentSink } from "../../tools/subagent.js";
|
||||
import { appendUsage } from "../../usage.js";
|
||||
import type { DisplayEvent } from "./EventLog.js";
|
||||
|
||||
export interface SubagentActivity {
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface UseSubagentParams {
|
||||
session: string | undefined;
|
||||
setHistorical: React.Dispatch<React.SetStateAction<DisplayEvent[]>>;
|
||||
}
|
||||
|
||||
export interface UseSubagentResult {
|
||||
/** Live state for an in-flight subagent, null when none is running. */
|
||||
activity: SubagentActivity | null;
|
||||
/**
|
||||
* Ref captured by the loop's `subagentRunner` closure so even
|
||||
* late-spawned subagents find the current handler. Consumers pass
|
||||
* this to `spawnSubagent({ sink })`.
|
||||
*/
|
||||
sinkRef: React.MutableRefObject<SubagentSink>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subagent UI wiring. `start` opens the activity row; each `progress`
|
||||
* updates iter + elapsed in place; `end` clears the row and posts a
|
||||
* one-line summary (with inline cost) into Historical. Only one
|
||||
* subagent is active at a time in the MVP — overlapping events just
|
||||
* replace the prior activity.
|
||||
*/
|
||||
export function useSubagent({ session, setHistorical }: UseSubagentParams): UseSubagentResult {
|
||||
const [activity, setActivity] = useState<SubagentActivity | null>(null);
|
||||
const sinkRef = useRef<SubagentSink>({ current: null });
|
||||
|
||||
useEffect(() => {
|
||||
sinkRef.current.current = (ev: SubagentEvent) => {
|
||||
if (ev.kind === "start") {
|
||||
setActivity({
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "progress") {
|
||||
setActivity({
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// end
|
||||
setActivity(null);
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
// Inline cost: the one number most users look at. Shown in the
|
||||
// Historical row so they don't have to run `/stats` to see it.
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0 ? ` · $${ev.costUsd.toFixed(4)}` : "";
|
||||
const summary = ev.error
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `subagent-end-${Date.now()}`,
|
||||
role: "info",
|
||||
text: summary,
|
||||
},
|
||||
]);
|
||||
// Persist a subagent summary row to ~/.reasonix/usage.jsonl so
|
||||
// `/stats` and `reasonix stats` surface it. Skipped on error —
|
||||
// we only record what actually cost money and did work.
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
sinkRef.current.current = null;
|
||||
};
|
||||
}, [session, setHistorical]);
|
||||
|
||||
return { activity, sinkRef };
|
||||
}
|
||||
+23
-1
@@ -110,7 +110,17 @@ export class DeepSeekClient {
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
// 11 min. DeepSeek's load-balancer may keep a connection open for
|
||||
// up to 10 minutes while the request waits in queue (non-streaming
|
||||
// sends empty lines, streaming sends `:` SSE keep-alive comments —
|
||||
// both are invisible to our parsers, so neither surfaces until the
|
||||
// real response starts). Timing out at the legacy 2-min default
|
||||
// killed queued requests prematurely, burned the queue slot on
|
||||
// retry, and could loop through the whole queue repeatedly.
|
||||
// Setting 11 min lets the server's own 10-min cap close the
|
||||
// connection first (clean EOF → natural retry), and our timer
|
||||
// is a safety net for genuinely hung sockets.
|
||||
this.timeoutMs = opts.timeoutMs ?? 660_000;
|
||||
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this.retry = opts.retry ?? {};
|
||||
}
|
||||
@@ -125,6 +135,18 @@ export class DeepSeekClient {
|
||||
if (opts.temperature !== undefined) payload.temperature = opts.temperature;
|
||||
if (opts.maxTokens !== undefined) payload.max_tokens = opts.maxTokens;
|
||||
if (opts.responseFormat) payload.response_format = opts.responseFormat;
|
||||
// V4 thinking-mode toggle: lives under `extra_body.thinking.type` per
|
||||
// DeepSeek's docs. Docs also note that in thinking mode `temperature`,
|
||||
// `top_p`, `presence_penalty`, `frequency_penalty` are silently
|
||||
// ignored — we don't strip them here because the server's explicit
|
||||
// "setting won't report an error" contract means leaving them in is
|
||||
// safe and keeps the request payload diffable against OpenAI tooling.
|
||||
if (opts.thinking) {
|
||||
payload.extra_body = { thinking: { type: opts.thinking } };
|
||||
}
|
||||
if (opts.reasoningEffort) {
|
||||
payload.reasoning_effort = opts.reasoningEffort;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,27 @@ export function applyEditBlocks(blocks: EditBlock[], rootDir: string): ApplyResu
|
||||
return blocks.map((b) => applyEditBlock(b, rootDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an EditBlock that represents a whole-file overwrite. If the
|
||||
* target exists, SEARCH = current content so applyEditBlock replaces
|
||||
* the whole thing when committed; if it doesn't, SEARCH is empty (the
|
||||
* create-new sentinel). Used by the edit-mode gate when routing a
|
||||
* `write_file` tool call through the review queue without executing
|
||||
* the write inline.
|
||||
*/
|
||||
export function toWholeFileEditBlock(path: string, content: string, rootDir: string): EditBlock {
|
||||
const abs = resolve(rootDir, path);
|
||||
let search = "";
|
||||
if (existsSync(abs)) {
|
||||
try {
|
||||
search = readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
search = "";
|
||||
}
|
||||
}
|
||||
return { path, search, replace: content, offset: 0 };
|
||||
}
|
||||
|
||||
// ---------- snapshot / restore (for /undo) ----------
|
||||
|
||||
export interface EditSnapshot {
|
||||
|
||||
+56
-6
@@ -15,9 +15,10 @@
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ESCALATION_CONTRACT, TUI_FORMATTING_RULES } from "../prompt-fragments.js";
|
||||
import { applyMemoryStack } from "../user-memory.js";
|
||||
|
||||
export const CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
|
||||
export const CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, list_directory, directory_tree, search_files, search_content, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell.
|
||||
|
||||
# Cite or shut up — non-negotiable
|
||||
|
||||
@@ -58,15 +59,17 @@ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit con
|
||||
- You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
|
||||
|
||||
|
||||
# Delegating to subagents via Skills (🧬)
|
||||
# Delegating to subagents via Skills
|
||||
|
||||
The pinned Skills index below lists playbooks you can invoke with \`run_skill\`. Skills marked with **🧬** spawn an **isolated subagent** — a fresh child loop that runs the playbook in its own context and returns only the final answer. The subagent's tool calls and reasoning never enter your context, so 🧬 skills are how you keep the main session lean.
|
||||
The pinned Skills index below lists playbooks you can invoke with \`run_skill\`. Entries tagged \`[🧬 subagent]\` spawn an **isolated subagent** — a fresh child loop that runs the playbook in its own context and returns only the final answer. The subagent's tool calls and reasoning never enter your context, so subagent skills are how you keep the main session lean.
|
||||
|
||||
**When you call \`run_skill\`, the \`name\` is ONLY the identifier before the tag** — e.g. \`run_skill({ name: "explore", arguments: "..." })\`, NOT \`"[🧬 subagent] explore"\` and NOT \`"explore [🧬 subagent]"\`. The tag is display sugar; the name argument is just the bare identifier.
|
||||
|
||||
Two built-ins ship by default:
|
||||
- **🧬 explore** — read-only investigation across the codebase. Use when the user says things like "find all places that...", "how does X work across the project", "survey the code for Y". Pass \`arguments\` describing the concrete question.
|
||||
- **🧬 research** — combines web search + code reading. Use for "is X supported by lib Y", "what's the canonical way to Z", "compare our impl to the spec".
|
||||
- **explore** \`[🧬 subagent]\` — read-only investigation across the codebase. Use when the user says things like "find all places that...", "how does X work across the project", "survey the code for Y". Pass \`arguments\` describing the concrete question.
|
||||
- **research** \`[🧬 subagent]\` — combines web search + code reading. Use for "is X supported by lib Y", "what's the canonical way to Z", "compare our impl to the spec".
|
||||
|
||||
When to delegate (call \`run_skill\` with a 🧬 skill):
|
||||
When to delegate (call \`run_skill\` with a subagent skill):
|
||||
- The task would otherwise need >5 file reads or searches.
|
||||
- You only need the conclusion, not the exploration trail.
|
||||
- The work is self-contained (you can describe it in one paragraph).
|
||||
@@ -89,6 +92,15 @@ In those cases, use tools to gather what you need, then reply in prose. No SEARC
|
||||
|
||||
When you do propose edits, the user will review them and decide whether to \`/apply\` or \`/discard\`. Don't assume they'll accept — write as if each edit will be audited, because it will.
|
||||
|
||||
Reasonix runs an **edit gate**. The user's current mode (\`review\` or \`auto\`) decides what happens to your writes; you DO NOT see which mode is active, and you SHOULD NOT ask. Write the same way in both cases.
|
||||
|
||||
- In \`auto\` mode \`edit_file\` / \`write_file\` calls land on disk immediately with an undo window — you'll get the normal "edit blocks: 1/1 applied" style response.
|
||||
- In \`review\` mode EACH \`edit_file\` / \`write_file\` call pauses tool dispatch while the user decides. You'll get one of these responses:
|
||||
- \`"edit blocks: 1/1 applied"\` — user approved it. Continue as normal.
|
||||
- \`"User rejected this edit to <path>. Don't retry the same SEARCH/REPLACE…"\` — user said no to THIS specific edit. Do NOT re-emit the same block, do NOT switch tools to sneak it past the gate (write_file → edit_file, or text-form SEARCH/REPLACE). Either take a clearly different approach or stop and ask the user what they want instead.
|
||||
- Text-form SEARCH/REPLACE blocks in your assistant reply queue for end-of-turn /apply — same "don't retry on rejection" rule.
|
||||
- If the user presses Esc mid-prompt the whole turn is aborted; you won't get another tool response. Don't keep spamming tool calls after an abort.
|
||||
|
||||
# Editing files
|
||||
|
||||
When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
|
||||
@@ -129,11 +141,49 @@ Two different rules depending on which tool:
|
||||
- **Filesystem tools** (\`read_file\`, \`list_directory\`, \`search_files\`, \`edit_file\`, etc.): paths are sandbox-relative. \`/\` means the project root, \`/src/foo.ts\` means \`<project>/src/foo.ts\`. Both relative (\`src/foo.ts\`) and POSIX-absolute (\`/src/foo.ts\`) forms work.
|
||||
- **\`run_command\`**: the command runs in a real OS shell with cwd pinned to the project root. Paths inside the shell command are interpreted by THAT shell, not by us. **Never use leading \`/\` in run_command arguments** — Windows treats \`/tests\` as drive-root \`F:\\tests\` (non-existent), POSIX shells treat it as filesystem root. Use plain relative paths (\`tests\`, \`./tests\`, \`src/loop.ts\`) instead.
|
||||
|
||||
# Foreground vs. background commands
|
||||
|
||||
You have TWO tools for running shell commands, and picking the right one is non-negotiable:
|
||||
|
||||
- \`run_command\` — blocks until the process exits. Use for: **tests, builds, lints, typechecks, git operations, one-shot scripts**. Anything that naturally returns in under a minute.
|
||||
- \`run_background\` — spawns and detaches after a brief startup window. Use for: **dev servers, watchers, any command with "dev" / "serve" / "watch" / "start" in the name**. Examples: \`npm run dev\`, \`pnpm dev\`, \`yarn start\`, \`vite\`, \`next dev\`, \`uvicorn app:app --reload\`, \`flask run\`, \`python -m http.server\`, \`cargo watch\`, \`tsc --watch\`, \`webpack serve\`.
|
||||
|
||||
**Never use run_command for a dev server.** It will block for 60s, time out, and the user will see a frozen tool call while the server was actually running fine. Always \`run_background\`, then \`job_output\` to peek at the logs when you need to verify something.
|
||||
|
||||
After \`run_background\`, tools available to you:
|
||||
- \`job_output(jobId, tailLines?)\` — read recent logs to verify startup / debug errors.
|
||||
- \`list_jobs\` — see every job this session (running + exited).
|
||||
- \`stop_job(jobId)\` — SIGTERM → SIGKILL after grace. Stop before switching port / config.
|
||||
|
||||
Don't re-start an already-running dev server — call \`list_jobs\` first when in doubt.
|
||||
|
||||
# Scope discipline on "run it" / "start it" requests
|
||||
|
||||
When the user's request is to **run / start / launch / serve / boot up** something, your job is ONLY:
|
||||
|
||||
1. Start it (\`run_background\` for dev servers, \`run_command\` for one-shots).
|
||||
2. Verify it came up (read a ready signal via \`job_output\`, or fetch the URL with \`web_fetch\` if they want you to confirm).
|
||||
3. Report what's running, where (URL / port / pid), and STOP.
|
||||
|
||||
Do NOT, in the same turn:
|
||||
- Run \`tsc\` / type-checkers / linters unless the user asked for it.
|
||||
- Scan for bugs to "proactively" fix. The page rendering is success.
|
||||
- Clean up unused imports, dead code, or refactor "while you're here."
|
||||
- Edit files to improve anything the user didn't mention.
|
||||
|
||||
If you notice an obvious issue, MENTION it in one sentence and wait for the user to say "fix it." The cost of over-eagerness is real: you burn tokens, make surprise edits the user didn't want, and chain into cascading "fix the new error I just introduced" loops. The storm-breaker will cut you off, but the user still sees the mess.
|
||||
|
||||
"It works" is the end state. Resist the urge to polish.
|
||||
|
||||
# Style
|
||||
|
||||
- Show edits; don't narrate them in prose. "Here's the fix:" is enough.
|
||||
- One short paragraph explaining *why*, then the blocks.
|
||||
- If you need to explore first (list / read / search), do it with tool calls before writing any prose — silence while exploring is fine.
|
||||
|
||||
${ESCALATION_CONTRACT}
|
||||
|
||||
${TUI_FORMATTING_RULES}
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,23 @@ import { dirname, join } from "node:path";
|
||||
/** One of the preset bundles (model + harvest + branch combo). */
|
||||
export type PresetName = "fast" | "smart" | "max";
|
||||
|
||||
/**
|
||||
* How `reasonix code` handles model-issued edits:
|
||||
* - "review" — queue the edit into pendingEdits; user /apply or `y` commits.
|
||||
* - "auto" — apply immediately, snapshot for /undo, show a short undo
|
||||
* banner so the user can roll back with one keystroke.
|
||||
* Persisted so `/mode auto` survives a relaunch. Missing → "review".
|
||||
*/
|
||||
export type EditMode = "review" | "auto";
|
||||
|
||||
/**
|
||||
* reasoning_effort cap for the model. "max" is the agent-class default;
|
||||
* "high" is cheaper / faster. Persisted so `/effort high` survives a
|
||||
* relaunch — earlier versions silently reverted to "max" on every new
|
||||
* session, which burned budget unexpectedly.
|
||||
*/
|
||||
export type ReasoningEffort = "high" | "max";
|
||||
|
||||
export interface ReasonixConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -29,6 +46,22 @@ export interface ReasonixConfig {
|
||||
* Maps to model + harvest + branch combos (see presets.ts). Missing → "fast".
|
||||
*/
|
||||
preset?: PresetName;
|
||||
/**
|
||||
* Edit-gate mode for `reasonix code`. See EditMode doc. Absent → "review".
|
||||
*/
|
||||
editMode?: EditMode;
|
||||
/**
|
||||
* Set to `true` the first time we've shown the "Shift+Tab cycles
|
||||
* review/AUTO" onboarding tip in `reasonix code`. Once seen, we stop
|
||||
* posting the tip — the bottom status bar carries the knowledge
|
||||
* forward without further nagging.
|
||||
*/
|
||||
editModeHintShown?: boolean;
|
||||
/**
|
||||
* Last reasoning_effort chosen via `/effort`. Loaded on launch so
|
||||
* "high" stays "high" — default is "max" when unset.
|
||||
*/
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
/**
|
||||
* Default MCP server specs to bridge on every `reasonix chat`, in the
|
||||
* same `"name=cmd args..."` format that `--mcp` takes. Stored as strings
|
||||
@@ -146,6 +179,57 @@ export function addProjectShellAllowed(
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted edit-mode. Unknown values fall back to "review" so
|
||||
* a user who hand-edits the file into an invalid state still gets the
|
||||
* safe default. `reasonix code` calls this at launch and the App lets
|
||||
* `/mode` / Shift+Tab flip it.
|
||||
*/
|
||||
export function loadEditMode(path: string = defaultConfigPath()): EditMode {
|
||||
const v = readConfig(path).editMode;
|
||||
return v === "auto" ? "auto" : "review";
|
||||
}
|
||||
|
||||
/** Persist the edit mode so `/mode auto` survives a relaunch. */
|
||||
export function saveEditMode(mode: EditMode, path: string = defaultConfigPath()): void {
|
||||
const cfg = readConfig(path);
|
||||
cfg.editMode = mode;
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
/** True when the onboarding tip for the review/AUTO gate has been shown. */
|
||||
export function editModeHintShown(path: string = defaultConfigPath()): boolean {
|
||||
return readConfig(path).editModeHintShown === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted reasoning_effort. Unknown / missing values fall
|
||||
* back to "max" so the agent-class default is never silently overridden
|
||||
* by a hand-edited bad value in config.json.
|
||||
*/
|
||||
export function loadReasoningEffort(path: string = defaultConfigPath()): ReasoningEffort {
|
||||
const v = readConfig(path).reasoningEffort;
|
||||
return v === "high" ? "high" : "max";
|
||||
}
|
||||
|
||||
/** Persist the reasoning_effort cap so `/effort high` survives a relaunch. */
|
||||
export function saveReasoningEffort(
|
||||
effort: ReasoningEffort,
|
||||
path: string = defaultConfigPath(),
|
||||
): void {
|
||||
const cfg = readConfig(path);
|
||||
cfg.reasoningEffort = effort;
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
/** Mark the onboarding tip as shown so subsequent launches skip it. */
|
||||
export function markEditModeHintShown(path: string = defaultConfigPath()): void {
|
||||
const cfg = readConfig(path);
|
||||
if (cfg.editModeHintShown === true) return;
|
||||
cfg.editModeHintShown = true;
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
export function isPlausibleKey(key: string): boolean {
|
||||
const trimmed = key.trim();
|
||||
return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
|
||||
|
||||
@@ -78,6 +78,12 @@ export async function harvest(
|
||||
const trimmed = reasoningContent.trim();
|
||||
if (trimmed.length < minLen) return emptyPlanState();
|
||||
|
||||
// Harvest is schema-constrained JSON extraction, not agent reasoning.
|
||||
// Default to `deepseek-chat` (= v4-flash non-thinking mode) — a few
|
||||
// hundred output tokens fit easily in the non-thinking budget, the
|
||||
// reply comes back ~10× faster than thinking mode, and the
|
||||
// per-turn cost stays an asterisk next to the main loop's spend
|
||||
// rather than a visible slice of it.
|
||||
const model = options.model ?? "deepseek-chat";
|
||||
const maxItems = options.maxItems ?? 5;
|
||||
const maxItemLen = options.maxItemLen ?? 80;
|
||||
@@ -96,6 +102,13 @@ export async function harvest(
|
||||
responseFormat: { type: "json_object" },
|
||||
temperature: 0,
|
||||
maxTokens: 600,
|
||||
// Pin mode + effort so a future default-model swap (e.g. someone
|
||||
// sets `options.model = "deepseek-v4-pro"`) can't accidentally
|
||||
// turn this micro-extraction into a multi-thousand-reasoning-
|
||||
// token call. DeepSeek ignores these on non-thinking models, so
|
||||
// the request stays valid regardless of the chosen model.
|
||||
thinking: "disabled",
|
||||
reasoningEffort: "high",
|
||||
signal,
|
||||
});
|
||||
return parsePlanState(resp.content, maxItems, maxItemLen);
|
||||
|
||||
+761
-38
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Reusable prompt fragments. Every rule that applies across multiple
|
||||
* prompt surfaces (main agent + subagents + built-in skills) lives
|
||||
* here so one edit covers every place the model learns the house
|
||||
* style. The 4-copy formatting-rule drift that motivated this was a
|
||||
* real bug: Unicode box-drawing warnings diverged between subagent
|
||||
* prompts, and the main code prompt lacked them entirely even though
|
||||
* its reply goes through the same markdown renderer.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How replies get rendered. Matches behaviors the TUI's markdown
|
||||
* pipeline (src/cli/ui/markdown.tsx) actually handles best.
|
||||
* Embedded literally into prompts — no string interpolation, so it
|
||||
* survives Reasonix's prefix-cache hashing unchanged across sessions.
|
||||
*/
|
||||
export const TUI_FORMATTING_RULES = `Formatting (rendered in a TUI with a real markdown renderer):
|
||||
- Tabular data → GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` header + \`| --- | --- |\` separator). Never use Unicode box-drawing characters (│ ─ ┼ ┌ ┐ └ ┘ ├ ┤) — they look intentional but break terminal word-wrap and render as garbled columns at narrow widths.
|
||||
- Keep table cells short (one phrase each). If a cell needs a paragraph, use bullets below the table instead.
|
||||
- Code, file paths with line ranges, and shell commands → fenced code blocks (\`\`\`).
|
||||
- Do NOT draw decorative frames around content with \`┌──┐ │ └──┘\` characters. The renderer adds its own borders; extra ASCII art adds noise and shatters at narrow widths.
|
||||
- For flow charts and diagrams: a plain bullet list with \`→\` or \`↓\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
|
||||
|
||||
/**
|
||||
* Self-report escalation contract — teaches the model (running on
|
||||
* flash by default) how to ask for a retry on v4-pro. When Reasonix
|
||||
* sees "<<<NEEDS_PRO>>>" as the first line of assistant output on
|
||||
* flash, it aborts the call, upgrades this turn to pro, and re-issues
|
||||
* the same messages. One retry per turn; pro never re-escalates.
|
||||
*
|
||||
* Why this exists: non-expert users can't reliably predict which
|
||||
* tasks need the frontier tier, and reactive-only escalation (wait
|
||||
* for flash to fail N times) wastes tokens + time. Letting the model
|
||||
* self-assess catches hard tasks upfront when flash recognizes the
|
||||
* difficulty — without silently auto-upgrading every prompt (which
|
||||
* would destroy cost savings).
|
||||
*
|
||||
* Use sparingly: most coding tasks stay on flash. The escape hatch
|
||||
* is for tasks where flash would otherwise emit a mediocre / guessed
|
||||
* answer.
|
||||
*/
|
||||
export const ESCALATION_CONTRACT = `Cost-aware escalation (when you're running on deepseek-v4-flash):
|
||||
|
||||
If a task CLEARLY exceeds what flash can do well — complex cross-file architecture refactors, subtle concurrency / security / correctness invariants you can't resolve with confidence, or a design trade-off you'd be guessing at — output the exact string \`<<<NEEDS_PRO>>>\` as the FIRST line of your response (nothing before it, not even whitespace on a separate line). This aborts the current call and retries this turn on deepseek-v4-pro, one shot. Do NOT emit any other content in the same response when you request escalation.
|
||||
|
||||
Use this sparingly. Normal tasks — reading files, small edits, clear bug fixes, straightforward feature additions — stay on flash. Request escalation ONLY when you would otherwise produce a guess or a visibly-mediocre answer. If in doubt, attempt the task on flash first; the system also escalates automatically if you hit 3+ repair / SEARCH-mismatch errors in a single turn.`;
|
||||
|
||||
/**
|
||||
* "Don't assert absence without checking." Subagents that run without
|
||||
* this rule will confidently report "X is missing" based on their
|
||||
* partial exploration, and the parent agent swallows it as fact. The
|
||||
* main code prompt has its own, longer version of this — kept separate
|
||||
* because the code agent is in a richer conversational frame.
|
||||
*/
|
||||
export const NEGATIVE_CLAIM_RULE = `Negative claims ("X is missing", "Y isn't implemented", "there's no Z") are the #1 hallucination shape. They feel safe to write because no citation seems possible — but that's exactly why you must NOT write them on instinct.
|
||||
|
||||
If you have a search tool (\`search_content\`, \`grep\`, web search), call it FIRST before asserting absence:
|
||||
- Returns matches → you were wrong; correct yourself and cite the matches.
|
||||
- Returns nothing → state the absence WITH the search query as evidence: \`No callers of \\\`foo()\\\` found (search_content "foo").\`
|
||||
|
||||
If you have no search tool, qualify hard: "I haven't verified — this is a guess." Never assert absence with fake authority.`;
|
||||
@@ -170,6 +170,7 @@ function summarizeTurns(turns: TurnStats[]): SessionSummary {
|
||||
savingsVsClaudePct: round(savingsVsClaude * 100, 2),
|
||||
cacheHitRatio: round(cacheHitRatio, 4),
|
||||
lastPromptTokens: lastTurn?.usage.promptTokens ?? 0,
|
||||
lastTurnCostUsd: round(lastTurn?.cost ?? 0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+20
-18
@@ -32,6 +32,7 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { NEGATIVE_CLAIM_RULE, TUI_FORMATTING_RULES } from "./prompt-fragments.js";
|
||||
|
||||
export const SKILLS_DIRNAME = "skills";
|
||||
export const SKILL_FILE = "SKILL.md";
|
||||
@@ -271,15 +272,22 @@ function parseRunAs(raw: string | undefined): SkillRunAs {
|
||||
* Build a single index line for one skill. Shape mirrors memory's
|
||||
* `indexLine` — a bullet suitable for a markdown fenced block in the
|
||||
* system prompt. Description is truncated to keep the full line under
|
||||
* ~150 chars. Subagent-runAs skills carry a 🧬 marker so the model
|
||||
* sees at a glance which skills will spawn an isolated child loop.
|
||||
* ~150 chars.
|
||||
*
|
||||
* Subagent-runAs skills carry a `[🧬 subagent]` tag AFTER the name
|
||||
* so the model can't confuse the marker for part of the skill name.
|
||||
* (Historical bug: when the marker led the name — `- 🧬 explore` —
|
||||
* models would call `run_skill({ name: "🧬 explore" })` verbatim
|
||||
* and fail lookup. Wrapping the marker in brackets AFTER the name
|
||||
* eliminates that confusion; `run_skill`'s name arg also strips any
|
||||
* leading non-word chars as a belt-and-suspenders measure.)
|
||||
*/
|
||||
function skillIndexLine(s: Pick<Skill, "name" | "description" | "runAs">): string {
|
||||
const safeDesc = s.description.replace(/\n/g, " ").trim();
|
||||
const marker = s.runAs === "subagent" ? "🧬 " : "";
|
||||
const max = 130 - s.name.length - marker.length;
|
||||
const tag = s.runAs === "subagent" ? " [🧬 subagent]" : "";
|
||||
const max = 130 - s.name.length - tag.length;
|
||||
const clipped = safeDesc.length > max ? `${safeDesc.slice(0, Math.max(1, max - 1))}…` : safeDesc;
|
||||
return clipped ? `- ${marker}${s.name} — ${clipped}` : `- ${marker}${s.name}`;
|
||||
return clipped ? `- ${s.name}${tag} — ${clipped}` : `- ${s.name}${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +316,7 @@ export function applySkillsIndex(basePrompt: string, opts: SkillStoreOptions = {
|
||||
"",
|
||||
"# Skills — playbooks you can invoke",
|
||||
"",
|
||||
'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })` to invoke one. Skills marked with 🧬 spawn an **isolated subagent** — its tool calls and reasoning never enter your context, only its final answer does. Use 🧬 skills for tasks that would otherwise flood your context (deep exploration, multi-step research, anything where you only need the conclusion). Plain skills are inlined: their body becomes a tool result you read and act on directly. The user can also invoke a skill via `/skill <name>`.',
|
||||
'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })` — the `name` is JUST the skill identifier (e.g. `"explore"`), NOT the `[🧬 subagent]` tag that appears after it. Entries tagged `[🧬 subagent]` spawn an **isolated subagent** — its tool calls and reasoning never enter your context, only its final answer does. Use subagent skills for tasks that would otherwise flood your context (deep exploration, multi-step research, anything where you only need the conclusion). Plain skills are inlined: their body becomes a tool result you read and act on directly. The user can also invoke a skill via `/skill <name>`.',
|
||||
"",
|
||||
"```",
|
||||
truncated,
|
||||
@@ -343,12 +351,9 @@ Your final answer:
|
||||
- If the question can't be answered from what you found, say so plainly and suggest where to look next.
|
||||
- No follow-up offers, no "let me know if you need more." The parent will ask again if they need more.
|
||||
|
||||
Formatting (rendered in a TUI):
|
||||
- Tabular data → GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` + \`| --- | --- |\`). Never use Unicode box-drawing characters (│ ─ ┼) — they break word-wrap.
|
||||
- Keep table cells short; if a cell needs a paragraph, use bullets below the table instead.
|
||||
- Code, file paths with line ranges, and shell commands → fenced code blocks (\`\`\`).
|
||||
- NEVER draw decorative frames around code or text with \`┌──┐ │ └──┘\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
|
||||
- For flow charts: use a bullet list with \`→\` or \`↓\` between steps, not ASCII boxes-and-arrows.
|
||||
${NEGATIVE_CLAIM_RULE}
|
||||
|
||||
${TUI_FORMATTING_RULES}
|
||||
|
||||
The 'task' the parent gave you is the question you must answer. Treat any other reading of it as scope creep.`;
|
||||
|
||||
@@ -366,12 +371,9 @@ Your final answer:
|
||||
- Distinguish "I verified this in code" from "I read this on a docs page" — the parent will trust the former more.
|
||||
- If the answer is uncertain, say so. Don't invent confidence.
|
||||
|
||||
Formatting (rendered in a TUI):
|
||||
- Tabular data → GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` + \`| --- | --- |\`). Never use Unicode box-drawing characters (│ ─ ┼) — they break word-wrap.
|
||||
- Keep table cells short; if a cell needs a paragraph, use bullets below the table instead.
|
||||
- Code, file paths with line ranges, and shell commands → fenced code blocks (\`\`\`).
|
||||
- NEVER draw decorative frames around code or text with \`┌──┐ │ └──┘\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
|
||||
- For flow charts: use a bullet list with \`→\` or \`↓\` between steps, not ASCII boxes-and-arrows.
|
||||
${NEGATIVE_CLAIM_RULE}
|
||||
|
||||
${TUI_FORMATTING_RULES}
|
||||
|
||||
The 'task' the parent gave you is the research question. Stay on it.`;
|
||||
|
||||
|
||||
+41
-16
@@ -1,24 +1,33 @@
|
||||
import type { Usage } from "./client.js";
|
||||
|
||||
/**
|
||||
* USD per 1M tokens. Source of truth is DeepSeek's CNY price sheet at
|
||||
* https://api-docs.deepseek.com/zh-cn/quick_start/pricing (as of
|
||||
* 2026-04-23): chat and reasoner are unified at ¥0.2 / ¥2 / ¥3 per 1M
|
||||
* tokens (cache-hit input / cache-miss input / output). Converted at
|
||||
* a fixed 7.2 CNY/USD rate so stats stay stable across the daily FX
|
||||
* USD per 1M tokens. Source: DeepSeek's CNY price sheet
|
||||
* (https://api-docs.deepseek.com/zh-cn/quick_start/pricing) converted
|
||||
* at a fixed 7.2 CNY/USD rate so billing stays stable across daily FX
|
||||
* drift; revisit if the rate moves more than ±5%.
|
||||
*
|
||||
* Historical note: the pre-unification prices were chat $0.07/$0.27/$1.10
|
||||
* and reasoner $0.14/$0.55/$2.19. Sessions logged under those values
|
||||
* in `~/.reasonix/usage.jsonl` remain as-is (USD frozen at record time)
|
||||
* — we never retroactively rewrite billing history.
|
||||
* 2026-04 V4 launch:
|
||||
* - deepseek-v4-flash ¥0.2 / ¥1 / ¥2 (hit / miss / out per 1M)
|
||||
* - deepseek-v4-pro ¥1 / ¥12 / ¥24
|
||||
*
|
||||
* deepseek-chat and deepseek-reasoner are now thin compat aliases for
|
||||
* v4-flash's non-thinking and thinking modes respectively — same
|
||||
* underlying model, same bill. We keep them in the table so existing
|
||||
* sessions (and configs that hard-code these names) keep pricing.
|
||||
*
|
||||
* Historical note: sessions logged before this file was updated remain
|
||||
* as-is in `~/.reasonix/usage.jsonl` — USD is frozen at record time,
|
||||
* we never retroactively rewrite billing history.
|
||||
*/
|
||||
export const DEEPSEEK_PRICING: Record<
|
||||
string,
|
||||
{ inputCacheHit: number; inputCacheMiss: number; output: number }
|
||||
> = {
|
||||
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
"deepseek-v4-flash": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
"deepseek-v4-pro": { inputCacheHit: 0.139, inputCacheMiss: 1.667, output: 3.333 },
|
||||
// Compat aliases — priced as v4-flash per the deprecation notice.
|
||||
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
};
|
||||
|
||||
/** Reference Claude Sonnet 4.6 pricing (USD per 1M tokens). */
|
||||
@@ -26,13 +35,21 @@ export const CLAUDE_SONNET_PRICING = { input: 3.0, output: 15.0 };
|
||||
|
||||
/**
|
||||
* Maximum prompt-side context window per DeepSeek model, in tokens.
|
||||
* Both V3 (`deepseek-chat`) and R1 (`deepseek-reasoner`) currently expose
|
||||
* a 131,072-token prompt limit per the OpenAPI spec; completion caps
|
||||
* differ but don't affect the prompt budget the StatsPanel shows.
|
||||
* V4 (flash + pro) jumps to 1,000,000 tokens. The compat aliases
|
||||
* (deepseek-chat / deepseek-reasoner) inherit that through the
|
||||
* v4-flash route — we bump them so the StatsPanel gauge reflects
|
||||
* what the API actually accepts.
|
||||
*
|
||||
* Completion caps (e.g. 384K for V4) are enforced by the server, not
|
||||
* tracked here — they don't affect the prompt-side budget the panel
|
||||
* shows. If a future feature surfaces output-cap warnings we'll add
|
||||
* a sibling table.
|
||||
*/
|
||||
export const DEEPSEEK_CONTEXT_TOKENS: Record<string, number> = {
|
||||
"deepseek-chat": 131_072,
|
||||
"deepseek-reasoner": 131_072,
|
||||
"deepseek-v4-flash": 1_000_000,
|
||||
"deepseek-v4-pro": 1_000_000,
|
||||
"deepseek-chat": 1_000_000,
|
||||
"deepseek-reasoner": 1_000_000,
|
||||
};
|
||||
|
||||
/** Fallback when the caller's model id isn't in the table — safe lower bound. */
|
||||
@@ -106,6 +123,13 @@ export interface SessionSummary {
|
||||
* prompt + user delta + any new tool outputs).
|
||||
*/
|
||||
lastPromptTokens: number;
|
||||
/**
|
||||
* Most recent turn's USD cost. Complements `totalCostUsd` so the TUI
|
||||
* can render "this turn: $X · session: $Y" — users asked for a
|
||||
* per-turn signal so a mid-session jump from flash to pro is
|
||||
* immediately visible, not hidden inside the session aggregate.
|
||||
*/
|
||||
lastTurnCostUsd: number;
|
||||
}
|
||||
|
||||
export class SessionStats {
|
||||
@@ -167,6 +191,7 @@ export class SessionStats {
|
||||
savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
|
||||
cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
|
||||
lastPromptTokens: last?.usage.promptTokens ?? 0,
|
||||
lastTurnCostUsd: round(last?.cost ?? 0, 6),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,22 @@ export interface ToolRegistryOptions {
|
||||
autoFlatten?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback form for `setToolInterceptor` — receives the tool name and
|
||||
* already-parsed arguments; returns a string to short-circuit dispatch
|
||||
* (the returned value becomes the tool result the model sees), or
|
||||
* `null` / `undefined` to fall through to the registered tool fn.
|
||||
*
|
||||
* Used by `reasonix code`'s edit-mode gate: `edit_file` / `write_file`
|
||||
* are intercepted in "review" mode (queued into pendingEdits, returning
|
||||
* "queued for /apply") or handled inline in "auto" mode (snapshot +
|
||||
* apply, then surface an undo banner). Other tools pass through.
|
||||
*/
|
||||
export type ToolInterceptor = (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) => string | null | undefined | Promise<string | null | undefined>;
|
||||
|
||||
export class ToolRegistry {
|
||||
private readonly _tools = new Map<string, InternalTool>();
|
||||
private readonly _autoFlatten: boolean;
|
||||
@@ -70,6 +86,12 @@ export class ToolRegistry {
|
||||
* bounced until the user approves a submitted plan.
|
||||
*/
|
||||
private _planMode = false;
|
||||
/**
|
||||
* Optional hook run after arg parsing but before tool.fn. Lets the TUI
|
||||
* reroute specific tool calls (e.g. edit_file in review mode) without
|
||||
* modifying the tool definitions themselves.
|
||||
*/
|
||||
private _interceptor: ToolInterceptor | null = null;
|
||||
|
||||
constructor(opts: ToolRegistryOptions = {}) {
|
||||
this._autoFlatten = opts.autoFlatten !== false;
|
||||
@@ -85,6 +107,15 @@ export class ToolRegistry {
|
||||
return this._planMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or clear the dispatch interceptor. At most one interceptor
|
||||
* is active at a time — calling twice replaces the previous. Pass
|
||||
* `null` to remove.
|
||||
*/
|
||||
setToolInterceptor(fn: ToolInterceptor | null): void {
|
||||
this._interceptor = fn;
|
||||
}
|
||||
|
||||
register<A, R>(def: ToolDefinition<A, R>): this {
|
||||
if (!def.name) throw new Error("tool requires a name");
|
||||
const internal: InternalTool = { ...(def as ToolDefinition) };
|
||||
@@ -167,6 +198,22 @@ export class ToolRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
// Interceptor runs after plan-mode (so a plan-mode refusal still
|
||||
// wins) but before the real tool fn. A string return is treated as
|
||||
// the full tool result; null / undefined means "not my concern,
|
||||
// fall through." Uncaught throws from the interceptor are surfaced
|
||||
// through the same error path as a failed tool fn below.
|
||||
if (this._interceptor) {
|
||||
try {
|
||||
const short = await this._interceptor(name, args);
|
||||
if (typeof short === "string") return short;
|
||||
} catch (err) {
|
||||
return JSON.stringify({
|
||||
error: `${name}: interceptor failed — ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.fn(args, { signal: opts.signal });
|
||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
||||
|
||||
+132
-20
@@ -48,6 +48,28 @@ export interface FilesystemToolsOptions {
|
||||
const DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_LIST_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* When `read_file` is called without `head` / `tail` / `range`, files
|
||||
* above this line count auto-trim to a head-plus-tail preview instead
|
||||
* of dumping everything into the turn's context. Observed: five
|
||||
* `read_file` calls in one session accounted for ~18K tokens (6.5K +
|
||||
* 3.9K + 3.2K + 2.4K + 1.6K), a sizable chunk of the 27K total in
|
||||
* tool results. Most of those reads wanted ~20 lines near one edit —
|
||||
* the other 480 lines were inventory the model never cites.
|
||||
*
|
||||
* 200 is a deliberate middle ground: typical CLI / config / test
|
||||
* files fit entirely; sprawling service files force the model to say
|
||||
* what it actually wants (`range:"120-180"` or `search_content`).
|
||||
*/
|
||||
const DEFAULT_AUTO_PREVIEW_LINES = 200;
|
||||
/**
|
||||
* When auto-preview triggers, show this many lines at the top
|
||||
* (structure / imports / public API) plus `AUTO_PREVIEW_TAIL_LINES`
|
||||
* at the bottom (often the recently-edited tail).
|
||||
*/
|
||||
const AUTO_PREVIEW_HEAD_LINES = 80;
|
||||
const AUTO_PREVIEW_TAIL_LINES = 40;
|
||||
|
||||
/**
|
||||
* Directory names skipped by `search_content` unless `include_deps:true`
|
||||
* is passed. The intent is "user is asking about THEIR code, not the
|
||||
@@ -171,8 +193,11 @@ export function registerFilesystemTools(
|
||||
|
||||
registry.register({
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
|
||||
description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
|
||||
- head: N → first N lines (imports, public API, small configs)
|
||||
- tail: N → last N lines (recently-added code, log tails)
|
||||
- range: "A-B" → inclusive line range A..B, 1-indexed (e.g. "120-180" around an edit site)
|
||||
When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_LINES} lines, the tool auto-returns a head+tail preview with an "N lines omitted" marker rather than dumping everything. If you need the middle, re-call with a range. Prefer search_content to locate a symbol first, then read_file with a range around the hit — one scoped read beats three full-file reads.`,
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -180,10 +205,15 @@ export function registerFilesystemTools(
|
||||
path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
|
||||
head: { type: "integer", description: "If set, return only the first N lines." },
|
||||
tail: { type: "integer", description: "If set, return only the last N lines." },
|
||||
range: {
|
||||
type: "string",
|
||||
description:
|
||||
'Inclusive line range like "50-100" or "50-50". 1-indexed. Takes precedence over head/tail when all three are set. Out-of-range requests clamp to file bounds.',
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
fn: async (args: { path: string; head?: number; tail?: number }) => {
|
||||
fn: async (args: { path: string; head?: number; tail?: number; range?: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
const stat = await fs.stat(abs);
|
||||
if (stat.isDirectory()) {
|
||||
@@ -191,22 +221,64 @@ export function registerFilesystemTools(
|
||||
}
|
||||
const raw = await fs.readFile(abs);
|
||||
if (raw.length > maxReadBytes) {
|
||||
const head = raw.slice(0, maxReadBytes).toString("utf8");
|
||||
return `${head}\n\n[…truncated ${raw.length - maxReadBytes} bytes — file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
|
||||
const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
|
||||
return `${headBytes}\n\n[…truncated ${raw.length - maxReadBytes} bytes — file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
|
||||
}
|
||||
const text = raw.toString("utf8");
|
||||
let lines = text.split(/\r?\n/);
|
||||
// Most files end with '\n' which splits into an empty trailing
|
||||
// entry; drop it so head/tail/range counts match the user's
|
||||
// visible line numbers in an editor.
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// range wins over head/tail when set — the most precise ask
|
||||
// should dominate. Parse "A-B" strictly; bad formats fall through
|
||||
// to head/tail / auto-preview instead of erroring.
|
||||
if (typeof args.range === "string" && /^\d+\s*-\s*\d+$/.test(args.range)) {
|
||||
const [rawStart, rawEnd] = args.range.split("-").map((s) => Number.parseInt(s, 10));
|
||||
const start = Math.max(1, rawStart ?? 1);
|
||||
const end = Math.min(totalLines, Math.max(start, rawEnd ?? totalLines));
|
||||
const slice = lines.slice(start - 1, end);
|
||||
const label = `[range ${start}-${end} of ${totalLines} lines]`;
|
||||
return `${label}\n${slice.join("\n")}`;
|
||||
}
|
||||
if (typeof args.head === "number" && args.head > 0) {
|
||||
return text.split(/\r?\n/).slice(0, args.head).join("\n");
|
||||
const count = Math.min(args.head, totalLines);
|
||||
const slice = lines.slice(0, count);
|
||||
const marker =
|
||||
count < totalLines
|
||||
? `\n\n[…head ${count} of ${totalLines} lines — call again with range / tail for more]`
|
||||
: "";
|
||||
return slice.join("\n") + marker;
|
||||
}
|
||||
if (typeof args.tail === "number" && args.tail > 0) {
|
||||
let lines = text.split(/\r?\n/);
|
||||
// Most files end with a final '\n', which splits into an empty
|
||||
// trailing string. Drop it before slicing so tail=2 returns the
|
||||
// last two *real* lines, not "last line + empty".
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
|
||||
return lines.slice(Math.max(0, lines.length - args.tail)).join("\n");
|
||||
const count = Math.min(args.tail, totalLines);
|
||||
const slice = lines.slice(totalLines - count);
|
||||
const marker =
|
||||
count < totalLines
|
||||
? `[…tail ${count} of ${totalLines} lines — call again with range / head for more]\n\n`
|
||||
: "";
|
||||
return marker + slice.join("\n");
|
||||
}
|
||||
return text;
|
||||
|
||||
// No explicit scope + file is small → full content.
|
||||
if (totalLines <= DEFAULT_AUTO_PREVIEW_LINES) return lines.join("\n");
|
||||
|
||||
// No explicit scope + file is large → head + tail preview plus
|
||||
// a marker telling the model how much it missed and how to get
|
||||
// it. This is the single biggest lever on read_file token cost —
|
||||
// historically a 500-line file dumped ~4K tokens into the turn
|
||||
// even when the model only needed 20 of them.
|
||||
const head = lines.slice(0, AUTO_PREVIEW_HEAD_LINES).join("\n");
|
||||
const tail = lines.slice(totalLines - AUTO_PREVIEW_TAIL_LINES).join("\n");
|
||||
const omitted = totalLines - AUTO_PREVIEW_HEAD_LINES - AUTO_PREVIEW_TAIL_LINES;
|
||||
return [
|
||||
`[auto-preview: head ${AUTO_PREVIEW_HEAD_LINES} + tail ${AUTO_PREVIEW_TAIL_LINES} of ${totalLines} lines]`,
|
||||
head,
|
||||
`\n[… ${omitted} lines omitted — call read_file again with range:"A-B" (1-indexed) or head / tail to get the middle]\n`,
|
||||
tail,
|
||||
].join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,22 +306,40 @@ export function registerFilesystemTools(
|
||||
|
||||
registry.register({
|
||||
name: "directory_tree",
|
||||
description:
|
||||
"Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
|
||||
description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
|
||||
- maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
|
||||
- Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
|
||||
- Large subtrees (>50 children) auto-collapse to "[N files, M dirs hidden — list_directory <path> to inspect]" so one huge folder can't dominate the output.
|
||||
Prefer \`list_directory\` for a single-level view, \`search_files\` to find specific paths, and \`search_content\` to find code.`,
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Root of the tree (default: sandbox root)." },
|
||||
maxDepth: { type: "integer", description: "Max recursion depth (default 4)." },
|
||||
maxDepth: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Max recursion depth (default 2). Depth 0 shows only the top-level entries; depth 2 is usually enough to see module structure.",
|
||||
},
|
||||
include_deps: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, also traverse node_modules / .git / dist / build / etc. Off by default — most exploration questions are about the user's own code.",
|
||||
},
|
||||
},
|
||||
},
|
||||
fn: async (args: { path?: string; maxDepth?: number }) => {
|
||||
fn: async (args: { path?: string; maxDepth?: number; include_deps?: boolean }) => {
|
||||
const startAbs = safePath(args.path ?? ".");
|
||||
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 4;
|
||||
const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 2;
|
||||
const includeDeps = args.include_deps === true;
|
||||
const lines: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
// Per-directory child cap — long fixture / asset folders (200+
|
||||
// snapshots) would otherwise dominate; the collapse keeps the
|
||||
// overall shape visible. Modest: normal source dirs have <50
|
||||
// entries.
|
||||
const PER_DIR_CHILD_CAP = 50;
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (truncated) return;
|
||||
if (depth > maxDepth) return;
|
||||
@@ -260,10 +350,31 @@ export function registerFilesystemTools(
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
let emitted = 0;
|
||||
for (const e of entries) {
|
||||
if (truncated) return;
|
||||
// Dep-skip applies only to DIRECTORIES (a file named
|
||||
// "node_modules" is fine to list). Anything in the skip set
|
||||
// still shows up as a single node with a trailing " (skipped)"
|
||||
// hint so the model knows the dir exists but wasn't walked.
|
||||
const skip = e.isDirectory() && !includeDeps && SKIP_DIR_NAMES.has(e.name);
|
||||
if (emitted >= PER_DIR_CHILD_CAP) {
|
||||
const remaining = entries.length - emitted;
|
||||
let restFiles = 0;
|
||||
let restDirs = 0;
|
||||
for (const r of entries.slice(emitted)) {
|
||||
if (r.isDirectory()) restDirs++;
|
||||
else restFiles++;
|
||||
}
|
||||
const indent = " ".repeat(depth);
|
||||
lines.push(
|
||||
`${indent}[… ${remaining} entries hidden (${restDirs} dirs, ${restFiles} files) — list_directory on this path to see all]`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const indent = " ".repeat(depth);
|
||||
const line = e.isDirectory() ? `${indent}${e.name}/` : `${indent}${e.name}`;
|
||||
const suffix = skip ? " (skipped — pass include_deps:true to traverse)" : "";
|
||||
const line = e.isDirectory() ? `${indent}${e.name}/${suffix}` : `${indent}${e.name}`;
|
||||
totalBytes += line.length + 1;
|
||||
if (totalBytes > maxListBytes) {
|
||||
lines.push(` [… tree truncated at ${maxListBytes} bytes …]`);
|
||||
@@ -271,7 +382,8 @@ export function registerFilesystemTools(
|
||||
return;
|
||||
}
|
||||
lines.push(line);
|
||||
if (e.isDirectory()) {
|
||||
emitted++;
|
||||
if (e.isDirectory() && !skip) {
|
||||
await walk(pathMod.join(dir, e.name), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Long-running process registry — the "background run" counterpart to
|
||||
* `run_command`. `run_command` spawns a child, waits for it to exit,
|
||||
* then returns combined output; perfect for tests / builds / one-shots
|
||||
* but useless for `npm run dev` / `python -m http.server` / watchers,
|
||||
* which never exit and just time the tool out.
|
||||
*
|
||||
* JobRegistry lets the model fire-and-almost-forget: we spawn the
|
||||
* child, wait at most `waitSec` (default 3s) OR until output matches
|
||||
* a readiness regex, then return the startup preview plus a job id.
|
||||
* The child keeps running in the background; later tool calls tail
|
||||
* its output, stop it, or list what's still alive.
|
||||
*
|
||||
* Shape-wise this is modeled on Claude Code's `BashOutput` / `KillBash`
|
||||
* pair. We diverge on one point: ready-signal detection is on by default
|
||||
* because dev servers almost universally print "Local:", "listening on",
|
||||
* "ready in N ms", "compiled successfully" when they come up — short-
|
||||
* circuiting the wait on those keeps the model's first tool-result
|
||||
* useful ("server is up at http://localhost:5173") instead of spending
|
||||
* the full 3s on a stabilization timer.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process";
|
||||
import * as pathMod from "node:path";
|
||||
import { detectShellOperator, prepareSpawn, tokenizeCommand } from "./shell.js";
|
||||
|
||||
/**
|
||||
* Kill an entire process tree rooted at `pid`.
|
||||
*
|
||||
* Why plain `child.kill(signal)` isn't enough:
|
||||
* - Windows: Node maps signals to `TerminateProcess(handle)`, which
|
||||
* only targets the direct child. `npm.cmd` spawned via cmd.exe
|
||||
* launches `node`, which spawns Vite / Webpack / etc. Killing the
|
||||
* npm wrapper leaves the whole JS server orphaned and still bound
|
||||
* to the port. `taskkill /T /F /PID` walks the tree and terminates
|
||||
* every descendant.
|
||||
* - POSIX: a normal signal goes to the child process only. If we
|
||||
* spawn with `detached:true` the child becomes a process-group
|
||||
* leader; `process.kill(-pid, signal)` then reaches every process
|
||||
* in that group, including grandchildren spawned after startup.
|
||||
*
|
||||
* Graceful vs forceful: SIGTERM gives the app a chance to cleanup; if
|
||||
* it ignores the signal we follow up with SIGKILL after a grace window
|
||||
* (handled by the caller, not here).
|
||||
*/
|
||||
function killProcessTree(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
||||
if (process.platform === "win32") {
|
||||
// taskkill: /T = tree, /F = force (TerminateProcess, no cleanup).
|
||||
// Graceful path still uses /F on Windows because there's no signal
|
||||
// in the POSIX sense — the closest equivalent is Ctrl+Break, which
|
||||
// is unreliable from another console. /F with /T is what most
|
||||
// process managers ship on Windows.
|
||||
const args = ["/pid", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") args.push("/F");
|
||||
try {
|
||||
const killer = spawn("taskkill", args, {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
// Swallow ENOENT / EACCES — we did our best. Not awaiting is
|
||||
// intentional: taskkill can take a few hundred ms and the caller
|
||||
// already has its own deadline.
|
||||
killer.on("error", () => {
|
||||
/* ignore */
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
// POSIX: negative pid signals the whole process group. Requires the
|
||||
// spawn to have been detached (which `start()` does below).
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
/* group-kill failed — fall back to direct */
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
/* ignore — already dead */
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-job output ring. Capped so a chatty dev server doesn't OOM. */
|
||||
const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/**
|
||||
* Regexes that signal "the server is up / the watcher has stabilized."
|
||||
* Case-insensitive. Matched against the accumulated stdout+stderr; first
|
||||
* hit cuts the startup wait short. Patterns are conservative — false
|
||||
* positives waste nothing (we'd have returned at waitSec anyway), but
|
||||
* a false negative costs the model a real stall.
|
||||
*/
|
||||
const READY_SIGNALS: ReadonlyArray<RegExp> = [
|
||||
// HTTP server banners
|
||||
/\blistening on\b/i,
|
||||
/\blocal:\s+https?:\/\//i,
|
||||
/\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?\b/i,
|
||||
/\b(?:ready|server started|started server|app listening)\b/i,
|
||||
// Bundlers / compilers
|
||||
/\bcompiled successfully\b/i,
|
||||
/\bbuild complete(?:d)?\b/i,
|
||||
/\bwatching for (?:file )?changes\b/i,
|
||||
/\bready in \d+/i,
|
||||
// Generic
|
||||
/\bstartup (?:complete|finished)\b/i,
|
||||
];
|
||||
|
||||
export interface JobStartOptions {
|
||||
/** Absolute path to cwd for the spawned child. */
|
||||
cwd: string;
|
||||
/**
|
||||
* Max seconds to wait for the initial burst before returning. Capped
|
||||
* at 30. A ready-signal match short-circuits this. Default 3.
|
||||
*/
|
||||
waitSec?: number;
|
||||
/** Signal plumbed through from the calling tool's AbortSignal. */
|
||||
signal?: AbortSignal;
|
||||
/** Total per-job output buffer cap (bytes). Default 64 KB. */
|
||||
maxBufferBytes?: number;
|
||||
}
|
||||
|
||||
export interface JobStartResult {
|
||||
jobId: number;
|
||||
pid: number | null;
|
||||
/** True iff the child was still running at the point we returned. */
|
||||
stillRunning: boolean;
|
||||
/** True iff a READY_SIGNALS pattern matched during the wait window. */
|
||||
readyMatched: boolean;
|
||||
/** Preview of combined stdout+stderr accumulated during the wait. */
|
||||
preview: string;
|
||||
/** If the child exited during the wait, its exit code; else null. */
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface JobRecord {
|
||||
id: number;
|
||||
command: string;
|
||||
pid: number | null;
|
||||
startedAt: number;
|
||||
/** Exit code once the process terminates; null while running. */
|
||||
exitCode: number | null;
|
||||
/** Combined stdout+stderr, ring-trimmed. */
|
||||
output: string;
|
||||
/**
|
||||
* Total bytes ever written by the child (not just what's in `output`).
|
||||
* Useful for "how much got dropped" diagnostics.
|
||||
*/
|
||||
totalBytesWritten: number;
|
||||
/** True iff the child is still alive. */
|
||||
running: boolean;
|
||||
/** Error from spawn() itself (ENOENT, etc.) once surfaced. */
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
export class JobRegistry {
|
||||
private readonly jobs = new Map<number, InternalJob>();
|
||||
private nextId = 1;
|
||||
|
||||
/**
|
||||
* Spawn a background child. Resolves after `waitSec` OR on ready
|
||||
* signal OR on early exit, whichever comes first. The child continues
|
||||
* to run (and buffer output) regardless of which path fires.
|
||||
*/
|
||||
async start(command: string, opts: JobStartOptions): Promise<JobStartResult> {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) throw new Error("run_background: empty command");
|
||||
const op = detectShellOperator(trimmed);
|
||||
if (op !== null) {
|
||||
throw new Error(
|
||||
`run_background: shell operator "${op}" is not supported — spawn one process per background job. Compose via your orchestration, not the shell.`,
|
||||
);
|
||||
}
|
||||
const argv = tokenizeCommand(trimmed);
|
||||
if (argv.length === 0) throw new Error("run_background: empty command");
|
||||
const waitMs = Math.max(0, Math.min(30, opts.waitSec ?? 3)) * 1000;
|
||||
const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
|
||||
|
||||
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: pathMod.resolve(opts.cwd),
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
// POSIX: detach so the child becomes its own process-group leader.
|
||||
// Required for `process.kill(-pid, …)` later — without it a group
|
||||
// kill fails and we end up only signaling the wrapper, leaving
|
||||
// grandchildren (node → vite → esbuild …) orphaned.
|
||||
// Windows: detached would spawn a new console window; leave the
|
||||
// default and use taskkill /T for tree termination.
|
||||
detached: process.platform !== "win32",
|
||||
...spawnOverrides,
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(bin, args, spawnOpts);
|
||||
} catch (err) {
|
||||
// Can't even spawn — record a dead job so the model sees the
|
||||
// failure in list_jobs, and return a synthetic result.
|
||||
const id = this.nextId++;
|
||||
const job: InternalJob = {
|
||||
id,
|
||||
command: trimmed,
|
||||
pid: null,
|
||||
startedAt: Date.now(),
|
||||
exitCode: null,
|
||||
output: `[spawn failed] ${(err as Error).message}`,
|
||||
totalBytesWritten: 0,
|
||||
running: false,
|
||||
spawnError: (err as Error).message,
|
||||
child: null,
|
||||
readyPromise: Promise.resolve(),
|
||||
signalReady: () => {},
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
return {
|
||||
jobId: id,
|
||||
pid: null,
|
||||
stillRunning: false,
|
||||
readyMatched: false,
|
||||
preview: job.output,
|
||||
exitCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
let readyResolve: () => void = () => {};
|
||||
const readyPromise = new Promise<void>((res) => {
|
||||
readyResolve = res;
|
||||
});
|
||||
const job: InternalJob = {
|
||||
id,
|
||||
command: trimmed,
|
||||
pid: child.pid ?? null,
|
||||
startedAt: Date.now(),
|
||||
exitCode: null,
|
||||
output: "",
|
||||
totalBytesWritten: 0,
|
||||
running: true,
|
||||
child,
|
||||
readyPromise,
|
||||
signalReady: readyResolve,
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
|
||||
let readyMatched = false;
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
const s = chunk.toString();
|
||||
job.totalBytesWritten += s.length;
|
||||
job.output += s;
|
||||
if (job.output.length > maxBytes) {
|
||||
// Drop the oldest bytes, but keep a marker so the model can see
|
||||
// output was truncated. Trim on a rough line boundary to avoid
|
||||
// chopping a line mid-sentence.
|
||||
const overflow = job.output.length - maxBytes;
|
||||
const cut = job.output.indexOf("\n", overflow);
|
||||
const start = cut >= 0 ? cut + 1 : overflow;
|
||||
job.output = `[… older output dropped …]\n${job.output.slice(start)}`;
|
||||
}
|
||||
if (!readyMatched) {
|
||||
for (const re of READY_SIGNALS) {
|
||||
if (re.test(s) || re.test(job.output)) {
|
||||
readyMatched = true;
|
||||
job.signalReady();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
child.on("error", (err) => {
|
||||
job.running = false;
|
||||
job.spawnError = err.message;
|
||||
job.signalReady();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
job.running = false;
|
||||
job.exitCode = code;
|
||||
job.signalReady();
|
||||
});
|
||||
|
||||
const onAbort = () => this.stop(id, { graceMs: 100 });
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
// Race: (a) ready signal, (b) child exit, (c) wait deadline.
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
await Promise.race([
|
||||
readyPromise,
|
||||
new Promise<void>((res) => {
|
||||
timer = setTimeout(res, waitMs);
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
return {
|
||||
jobId: id,
|
||||
pid: job.pid,
|
||||
stillRunning: job.running,
|
||||
readyMatched,
|
||||
preview: job.output,
|
||||
exitCode: job.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a job's accumulated output. `since` lets a caller poll
|
||||
* incrementally: pass the byte count returned from the last call to
|
||||
* get only newly-written content. Returns both full output and a
|
||||
* running snapshot so the caller can use whichever.
|
||||
*/
|
||||
read(id: number, opts: { since?: number; tailLines?: number } = {}): JobReadResult | null {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) return null;
|
||||
const full = job.output;
|
||||
let slice = full;
|
||||
if (typeof opts.since === "number" && opts.since >= 0 && opts.since < full.length) {
|
||||
slice = full.slice(opts.since);
|
||||
}
|
||||
if (typeof opts.tailLines === "number" && opts.tailLines > 0) {
|
||||
const lines = slice.split("\n");
|
||||
const keep = lines.slice(Math.max(0, lines.length - opts.tailLines));
|
||||
slice = keep.join("\n");
|
||||
}
|
||||
return {
|
||||
output: slice,
|
||||
byteLength: full.length,
|
||||
running: job.running,
|
||||
exitCode: job.exitCode,
|
||||
command: job.command,
|
||||
pid: job.pid,
|
||||
spawnError: job.spawnError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SIGTERM, wait `graceMs`, then SIGKILL if still alive. Returns
|
||||
* the final job record (or null when the job id is unknown). Safe to
|
||||
* call on an already-exited job — returns the record unchanged.
|
||||
*/
|
||||
async stop(id: number, opts: { graceMs?: number } = {}): Promise<JobRecord | null> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) return null;
|
||||
if (!job.running || !job.child) return snapshot(job);
|
||||
const graceMs = Math.max(0, opts.graceMs ?? 2000);
|
||||
// Tree kill — reaches grandchildren (vite, esbuild, etc.) instead
|
||||
// of just the npm/cmd.exe wrapper that our direct child represents.
|
||||
// Falls back to child.kill() only when we somehow don't have a pid.
|
||||
if (job.pid !== null) {
|
||||
killProcessTree(job.pid, "SIGTERM");
|
||||
} else {
|
||||
try {
|
||||
job.child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* already dead — fall through */
|
||||
}
|
||||
}
|
||||
// Wait for the close event or graceMs, then SIGKILL.
|
||||
await Promise.race([job.readyPromise, new Promise<void>((res) => setTimeout(res, graceMs))]);
|
||||
if (job.running) {
|
||||
if (job.pid !== null) {
|
||||
killProcessTree(job.pid, "SIGKILL");
|
||||
} else {
|
||||
try {
|
||||
job.child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Give the OS a moment to reap the tree so our exitCode field
|
||||
// catches it before we return. Windows taskkill can take up to
|
||||
// ~700ms to propagate on a three-level tree (npm → node → vite).
|
||||
await new Promise<void>((res) => setTimeout(res, 800));
|
||||
}
|
||||
return snapshot(job);
|
||||
}
|
||||
|
||||
list(): JobRecord[] {
|
||||
return [...this.jobs.values()].map(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort kill of every still-running job. Called on TUI shutdown
|
||||
* so dev servers don't outlive the Reasonix process. Resolves after
|
||||
* every child has closed or a hard deadline passes (3s total).
|
||||
*/
|
||||
async shutdown(deadlineMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
const runningJobs = [...this.jobs.values()].filter((j) => j.running && j.child);
|
||||
if (runningJobs.length === 0) return;
|
||||
|
||||
for (const job of runningJobs) {
|
||||
if (job.pid !== null) killProcessTree(job.pid, "SIGTERM");
|
||||
else
|
||||
try {
|
||||
job.child?.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const allClose = Promise.all(runningJobs.map((j) => j.readyPromise));
|
||||
const elapsed = () => Date.now() - start;
|
||||
// Grace window: give well-behaved apps time to clean up, capped at
|
||||
// half the deadline so we always leave room for a SIGKILL pass +
|
||||
// reap confirmation.
|
||||
const graceMs = Math.min(1500, Math.max(0, deadlineMs / 2));
|
||||
await Promise.race([allClose, new Promise<void>((res) => setTimeout(res, graceMs))]);
|
||||
// Force-kill everything still alive.
|
||||
for (const job of runningJobs) {
|
||||
if (!job.running) continue;
|
||||
if (job.pid !== null) killProcessTree(job.pid, "SIGKILL");
|
||||
else
|
||||
try {
|
||||
job.child?.kill("SIGKILL");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Wait for close events post-SIGKILL. taskkill /T on Windows is
|
||||
// async — without this final wait, shutdown() can return while
|
||||
// grandchildren are still mid-teardown, which is what "runningCount
|
||||
// non-zero after shutdown" looks like.
|
||||
const remaining = Math.max(800, deadlineMs - elapsed());
|
||||
await Promise.race([allClose, new Promise<void>((res) => setTimeout(res, remaining))]);
|
||||
}
|
||||
|
||||
/** Count of still-running jobs — drives the TUI status-bar indicator. */
|
||||
runningCount(): number {
|
||||
let n = 0;
|
||||
for (const job of this.jobs.values()) if (job.running) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
interface InternalJob extends JobRecord {
|
||||
/** Underlying Node child process. Null only on spawn failure. */
|
||||
child: ChildProcess | null;
|
||||
/** Resolved when ready-signal fires OR the child exits. */
|
||||
readyPromise: Promise<void>;
|
||||
/** Fires readyPromise — called by ready-signal OR close/error handlers. */
|
||||
signalReady: () => void;
|
||||
}
|
||||
|
||||
export interface JobReadResult {
|
||||
output: string;
|
||||
/** Total bytes ever in the buffer (pre-slice). Caller passes back as `since`. */
|
||||
byteLength: number;
|
||||
running: boolean;
|
||||
exitCode: number | null;
|
||||
command: string;
|
||||
pid: number | null;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
function snapshot(job: InternalJob): JobRecord {
|
||||
return {
|
||||
id: job.id,
|
||||
command: job.command,
|
||||
pid: job.pid,
|
||||
startedAt: job.startedAt,
|
||||
exitCode: job.exitCode,
|
||||
output: job.output,
|
||||
totalBytesWritten: job.totalBytesWritten,
|
||||
running: job.running,
|
||||
spawnError: job.spawnError,
|
||||
};
|
||||
}
|
||||
+157
-1
@@ -30,6 +30,7 @@ import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import type { ToolRegistry } from "../tools.js";
|
||||
import { JobRegistry } from "./jobs.js";
|
||||
|
||||
export interface ShellToolsOptions {
|
||||
/** Directory to run commands in. Must be an absolute path. */
|
||||
@@ -58,6 +59,13 @@ export interface ShellToolsOptions {
|
||||
* (CI, benchmarks) where a human can't be in the loop to confirm.
|
||||
*/
|
||||
allowAll?: boolean;
|
||||
/**
|
||||
* Background-process registry shared between `run_background`,
|
||||
* `job_output`, `stop_job`, `list_jobs`, and the /jobs /kill slashes.
|
||||
* When omitted, the registrar builds its own — but the caller
|
||||
* usually wants to provide one so the TUI can tail it too.
|
||||
*/
|
||||
jobs?: JobRegistry;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_SEC = 60;
|
||||
@@ -576,6 +584,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
const rootDir = pathMod.resolve(opts.rootDir);
|
||||
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
||||
const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
||||
const jobs = opts.jobs ?? new JobRegistry();
|
||||
// Resolved on every dispatch so newly-persisted "always allow"
|
||||
// prefixes take effect inside the session that added them, not just
|
||||
// on the next launch. Static arrays are wrapped into a constant
|
||||
@@ -592,7 +601,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
registry.register({
|
||||
name: "run_command",
|
||||
description:
|
||||
"Run a shell command in the project root and return its combined stdout+stderr. Common read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually — after edits, run the project's tests to verify.",
|
||||
"Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n• ONE process per call, NO shell expansion. `&&`, `||`, `|`, `;`, `>`, `<`, `2>&1` are all rejected up-front — split into separate calls and combine results in reasoning. Example: instead of `grep foo *.ts | wc -l`, use `grep -c foo *.ts`; instead of `cd sub && npm test`, use `npm test --prefix sub` (or whatever --cwd flag the binary accepts).\n• `cd` DOES NOT PERSIST between calls — each call spawns a fresh process rooted at the project. If a tool needs a subdirectory, pass it via the tool's own flag (`npm --prefix`, `cargo -C`, `git -C`, `pytest tests/…`), NOT via a preceding `cd`.\n• Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) — they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually — after edits, run the project's tests to verify.",
|
||||
// Plan-mode gate: allow allowlisted commands through (git status,
|
||||
// cargo check, ls, grep …) so the model can actually investigate
|
||||
// during planning. Anything that would otherwise trigger a
|
||||
@@ -635,9 +644,156 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "run_background",
|
||||
description:
|
||||
"Spawn a long-running process (dev server, watcher, any command that doesn't naturally exit) and detach. Waits up to `waitSec` seconds for startup (or until the output matches a readiness signal like 'Local:', 'listening on', 'compiled successfully'), then returns the job id + startup preview. The process keeps running; call `job_output` to tail its logs, `stop_job` to kill it, `list_jobs` to see all running jobs.\n\nSame shell constraints as run_command: NO `&&` / `||` / `|` / `;` / `>` / `<` / `2>&1`, `cd` doesn't persist. Dev servers that need a subdirectory: use the tool's own --prefix / --cwd flag. For Vite specifically, `--prefix` on npm only tells npm where package.json is; vite's server root still defaults to process cwd, so pass `vite <project-dir>` or configure via `vite.config.ts` root.\n\nUSE THIS — not `run_command` — for: npm/yarn/pnpm run dev, uvicorn / flask run, go run, cargo watch, tsc --watch, webpack serve, anything with 'dev' / 'serve' / 'watch' in the name.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"Full command line. Same quoting rules as run_command (no pipes / redirects / chaining).",
|
||||
},
|
||||
waitSec: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Max seconds to wait for startup before returning. 0..30, default 3. A ready-signal match short-circuits this.",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
fn: async (args: { command: string; waitSec?: number }, ctx) => {
|
||||
const cmd = args.command.trim();
|
||||
if (!cmd) throw new Error("run_background: empty command");
|
||||
if (!allowAll && !isAllowed(cmd, getExtraAllowed())) {
|
||||
throw new NeedsConfirmationError(cmd);
|
||||
}
|
||||
const result = await jobs.start(cmd, {
|
||||
cwd: rootDir,
|
||||
waitSec: args.waitSec,
|
||||
signal: ctx?.signal,
|
||||
});
|
||||
return formatJobStart(result);
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "job_output",
|
||||
description:
|
||||
"Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jobId: { type: "integer", description: "Job id returned by run_background." },
|
||||
since: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Return only output written past this byte offset (for incremental polling).",
|
||||
},
|
||||
tailLines: {
|
||||
type: "integer",
|
||||
description: "Cap the returned slice to the last N lines. Default 80, 0 = unlimited.",
|
||||
},
|
||||
},
|
||||
required: ["jobId"],
|
||||
},
|
||||
fn: async (args: { jobId: number; since?: number; tailLines?: number }) => {
|
||||
const out = jobs.read(args.jobId, {
|
||||
since: args.since,
|
||||
tailLines: args.tailLines ?? 80,
|
||||
});
|
||||
if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
|
||||
return formatJobRead(args.jobId, out);
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "stop_job",
|
||||
description:
|
||||
"Stop a background job started with `run_background`. SIGTERM first; SIGKILL after a short grace period if it doesn't exit cleanly. Returns the final output + exit code. Safe to call on an already-exited job.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jobId: { type: "integer" },
|
||||
},
|
||||
required: ["jobId"],
|
||||
},
|
||||
fn: async (args: { jobId: number }) => {
|
||||
const rec = await jobs.stop(args.jobId);
|
||||
if (!rec) return `job ${args.jobId}: not found`;
|
||||
return formatJobStop(rec);
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "list_jobs",
|
||||
description:
|
||||
"List every background job started this session — running and exited — with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
|
||||
readOnly: true,
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: async () => {
|
||||
const all = jobs.list();
|
||||
if (all.length === 0) return "(no background jobs started this session)";
|
||||
return all.map(formatJobRow).join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
function formatJobStart(r: import("./jobs.js").JobStartResult): string {
|
||||
const header = r.stillRunning
|
||||
? `[job ${r.jobId} started · pid ${r.pid ?? "?"} · ${r.readyMatched ? "READY signal matched" : "running (no ready signal yet)"}]`
|
||||
: r.exitCode !== null
|
||||
? `[job ${r.jobId} exited during startup · exit ${r.exitCode}]`
|
||||
: `[job ${r.jobId} failed to start]`;
|
||||
return r.preview ? `${header}\n${r.preview}` : header;
|
||||
}
|
||||
|
||||
function formatJobRead(jobId: number, r: import("./jobs.js").JobReadResult): string {
|
||||
const status = r.running
|
||||
? `running · pid ${r.pid ?? "?"}`
|
||||
: r.exitCode !== null
|
||||
? `exited ${r.exitCode}`
|
||||
: r.spawnError
|
||||
? `failed (${r.spawnError})`
|
||||
: "stopped";
|
||||
const header = `[job ${jobId} · ${status} · byteLength=${r.byteLength}]\n$ ${r.command}`;
|
||||
return r.output ? `${header}\n${r.output}` : header;
|
||||
}
|
||||
|
||||
function formatJobStop(r: import("./jobs.js").JobRecord): string {
|
||||
const running = r.running
|
||||
? "still running (SIGKILL may be pending)"
|
||||
: `exit ${r.exitCode ?? "?"}`;
|
||||
const tail = tailLines(r.output, 40);
|
||||
const header = `[job ${r.id} stopped · ${running}]\n$ ${r.command}`;
|
||||
return tail ? `${header}\n${tail}` : header;
|
||||
}
|
||||
|
||||
function formatJobRow(r: import("./jobs.js").JobRecord): string {
|
||||
const age = ((Date.now() - r.startedAt) / 1000).toFixed(1);
|
||||
const state = r.running
|
||||
? `running · pid ${r.pid ?? "?"}`
|
||||
: r.exitCode !== null
|
||||
? `exit ${r.exitCode}`
|
||||
: r.spawnError
|
||||
? "failed"
|
||||
: "stopped";
|
||||
return ` ${String(r.id).padStart(3)} ${state.padEnd(24)} ${age}s ago $ ${r.command}`;
|
||||
}
|
||||
|
||||
function tailLines(s: string, n: number): string {
|
||||
if (!s) return "";
|
||||
const lines = s.split("\n");
|
||||
if (lines.length <= n) return s;
|
||||
const dropped = lines.length - n;
|
||||
return [`[… ${dropped} earlier lines …]`, ...lines.slice(-n)].join("\n");
|
||||
}
|
||||
|
||||
export function formatCommandResult(cmd: string, r: RunCommandResult): string {
|
||||
const header = r.timedOut
|
||||
? `$ ${cmd}\n[killed after timeout]`
|
||||
|
||||
+22
-4
@@ -77,7 +77,7 @@ export function registerSkillTools(
|
||||
registry.register({
|
||||
name: "run_skill",
|
||||
description:
|
||||
"Invoke a playbook from the Skills index pinned in the system prompt. Each entry is a self-contained instruction block. Skills marked with 🧬 in the index spawn an isolated subagent — only the final distilled answer comes back, the model's tool calls + reasoning during the run never enter your context. Plain skills are inlined: the body becomes a tool result you read and follow. For 🧬 subagent skills, supply 'arguments' describing the concrete task — they'll be the only context the subagent has.",
|
||||
"Invoke a playbook from the Skills index pinned in the system prompt. Each entry is a self-contained instruction block. Pass `name` as the BARE skill identifier (e.g. 'explore'), NOT the `[🧬 subagent]` tag that appears after it in the index. Entries tagged `[🧬 subagent]` spawn an isolated subagent — only the final distilled answer comes back, the model's tool calls + reasoning during the run never enter your context. Plain skills are inlined: the body becomes a tool result you read and follow. For subagent skills, supply 'arguments' describing the concrete task — they'll be the only context the subagent has.",
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -90,16 +90,34 @@ export function registerSkillTools(
|
||||
arguments: {
|
||||
type: "string",
|
||||
description:
|
||||
"Free-form arguments the skill should act on. For inline skills: appended to the body as an 'Arguments:' line; the skill's own instructions decide how to consume them. For 🧬 subagent skills: REQUIRED — becomes the entire task description the subagent receives, since it has no other context.",
|
||||
"Free-form arguments the skill should act on. For inline skills: appended to the body as an 'Arguments:' line; the skill's own instructions decide how to consume them. For `[🧬 subagent]` skills: REQUIRED — becomes the entire task description the subagent receives, since it has no other context.",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
fn: async (args: { name?: unknown; arguments?: unknown }) => {
|
||||
const name = typeof args.name === "string" ? args.name.trim() : "";
|
||||
if (!name) {
|
||||
const raw = typeof args.name === "string" ? args.name.trim() : "";
|
||||
if (!raw) {
|
||||
return JSON.stringify({ error: "run_skill requires a 'name' argument" });
|
||||
}
|
||||
// Defensive: The Skills index writes entries like
|
||||
// `explore [🧬 subagent]`, and models sometimes copy the
|
||||
// decoration verbatim into the `name` argument instead of just
|
||||
// the identifier. Rather than reject those calls:
|
||||
// 1. Drop any `[...]` bracketed tag (possibly containing
|
||||
// emoji + "subagent" label).
|
||||
// 2. Find the first whitespace-delimited token whose first
|
||||
// char is alphanumeric — that's the skill identifier,
|
||||
// whether the tag came before or after the name.
|
||||
const stripped = raw.replace(/\[[^\]]*\]/g, " ").trim();
|
||||
const tokens = stripped.split(/\s+/).filter(Boolean);
|
||||
const name = tokens.find((t) => /^[a-zA-Z0-9]/.test(t)) ?? "";
|
||||
if (!name) {
|
||||
return JSON.stringify({
|
||||
error: "run_skill requires a 'name' argument",
|
||||
hint: `'${raw}' is just a marker/tag, not a skill name`,
|
||||
});
|
||||
}
|
||||
const skill = store.read(name);
|
||||
if (!skill) {
|
||||
const available = store
|
||||
|
||||
+28
-11
@@ -45,6 +45,11 @@ import { type DeepSeekClient, Usage } from "../client.js";
|
||||
import { CacheFirstLoop } from "../loop.js";
|
||||
import { ImmutablePrefix } from "../memory.js";
|
||||
import { applyProjectMemory } from "../project-memory.js";
|
||||
import {
|
||||
ESCALATION_CONTRACT,
|
||||
NEGATIVE_CLAIM_RULE,
|
||||
TUI_FORMATTING_RULES,
|
||||
} from "../prompt-fragments.js";
|
||||
import { ToolRegistry } from "../tools.js";
|
||||
|
||||
/**
|
||||
@@ -109,7 +114,7 @@ export interface SpawnSubagentOptions {
|
||||
system: string;
|
||||
/** The task / question / instruction the subagent must address. */
|
||||
task: string;
|
||||
/** Model id for the child loop. Defaults to `deepseek-chat`. */
|
||||
/** Model id for the child loop. Defaults to `deepseek-v4-pro`. */
|
||||
model?: string;
|
||||
/** Iteration ceiling for the child loop. Defaults to 16. */
|
||||
maxToolIters?: number;
|
||||
@@ -173,7 +178,7 @@ export interface SubagentToolOptions {
|
||||
defaultSystem?: string;
|
||||
/** Project root for `applyProjectMemory` lookup. Omit in chat mode. */
|
||||
projectRoot?: string;
|
||||
/** Default model. `deepseek-chat` (V3) by default. */
|
||||
/** Default model. `deepseek-v4-pro` by default. */
|
||||
defaultModel?: string;
|
||||
/** Iteration ceiling. Lower than the parent (16 by default). */
|
||||
maxToolIters?: number;
|
||||
@@ -191,16 +196,24 @@ Rules:
|
||||
- When you're done, your final assistant message is the only thing the parent will see — make it complete and self-contained. No follow-up offers, no questions, no "let me know if you need more."
|
||||
- Prefer one clear, distilled answer over a long log of what you tried.
|
||||
|
||||
Formatting rules (the parent renders your reply in a TUI with a real markdown renderer):
|
||||
- For tabular data use GitHub-Flavored Markdown tables with ASCII pipes: \`| col | col |\` headers, \`| --- | --- |\` separator. NEVER draw tables with Unicode box-drawing characters (│ ─ ┼ ┌ ┐ └ ┘ ├ ┤). They look intentional but break terminal word-wrap and produce garbled output.
|
||||
- Keep table cells short — one short phrase per cell, not multi-line paragraphs. If a description doesn't fit in ~40 chars, use bullets below the table instead.
|
||||
- Use fenced code blocks (\`\`\`) for any code, file paths with line ranges, or shell commands.
|
||||
- NEVER draw decorative frames around content with \`┌──┐ │ └──┘\` box-drawing characters. The renderer handles code blocks and headings on its own — extra ASCII art adds noise without value and breaks at narrow terminal widths.
|
||||
- For flow charts and diagrams: use a markdown bullet list with \`→\` or \`↓\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
|
||||
${NEGATIVE_CLAIM_RULE}
|
||||
|
||||
${ESCALATION_CONTRACT}
|
||||
|
||||
${TUI_FORMATTING_RULES}`;
|
||||
|
||||
const DEFAULT_MAX_RESULT_CHARS = 8000;
|
||||
const DEFAULT_MAX_ITERS = 16;
|
||||
const DEFAULT_SUBAGENT_MODEL = "deepseek-chat";
|
||||
// Subagents default to flash — their work is read-and-synthesize
|
||||
// (explore, research), which doesn't need the 12× pro tier. Skill
|
||||
// frontmatter `model: deepseek-v4-pro` is the opt-in override for
|
||||
// skills that empirically benefit from the stronger model.
|
||||
const DEFAULT_SUBAGENT_MODEL = "deepseek-v4-flash";
|
||||
// Subagents default to effort=high — less thinking budget than a
|
||||
// main turn (which defaults to `max` in the preset). The parent's
|
||||
// task arg is already a distilled prompt; explore/research rarely
|
||||
// need deep chains of thought, and `high` saves output tokens.
|
||||
const DEFAULT_SUBAGENT_EFFORT: "high" | "max" = "high";
|
||||
|
||||
const SUBAGENT_TOOL_NAME = "spawn_subagent";
|
||||
/**
|
||||
@@ -250,6 +263,10 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
prefix: childPrefix,
|
||||
tools: childTools,
|
||||
model,
|
||||
// Subagents run on a constrained thinking budget by default — the
|
||||
// task is already narrow by construction, and `high` cuts output
|
||||
// tokens substantially vs `max`.
|
||||
reasoningEffort: DEFAULT_SUBAGENT_EFFORT,
|
||||
maxToolIters,
|
||||
hooks: [],
|
||||
stream: false,
|
||||
@@ -423,9 +440,9 @@ export function registerSubagentTool(
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
enum: ["deepseek-chat", "deepseek-reasoner"],
|
||||
enum: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
|
||||
description:
|
||||
"Which DeepSeek model the subagent runs on. 'deepseek-chat' (V3) is the default — fast and cheap. Use 'deepseek-reasoner' (R1) only when the subtask genuinely needs planning or multi-step reasoning; it is roughly 5-10x more expensive.",
|
||||
"Which DeepSeek model the subagent runs on. Default is 'deepseek-v4-pro' — the strongest model, best for complex subtasks. Override to 'deepseek-v4-flash' (or the legacy 'deepseek-chat' / 'deepseek-reasoner' aliases, which route to flash non-thinking / thinking modes) when the subtask is simple enough that flash's quality suffices — flash is roughly 12× cheaper.",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface ChatMessage {
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
/**
|
||||
* R1 `reasoning_content` captured from the assistant's thinking turn.
|
||||
* DeepSeek's thinking mode 400s with "reasoning_content in the
|
||||
* thinking mode must be passed back" when a tool-loop continuation
|
||||
* omits it from the preceding assistant message. Round-tripped for
|
||||
* deepseek-reasoner turns with tool_calls; absent for deepseek-chat.
|
||||
*/
|
||||
reasoning_content?: string | null;
|
||||
}
|
||||
|
||||
export interface RawUsage {
|
||||
@@ -56,4 +64,19 @@ export interface ChatRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
/** DeepSeek response_format — use { type: "json_object" } to force valid JSON. */
|
||||
responseFormat?: { type: "json_object" | "text" };
|
||||
/**
|
||||
* Explicitly toggle V4 thinking mode. Serialized as
|
||||
* `extra_body.thinking.type = enabled|disabled`. Omit to let the
|
||||
* server default apply (thinking enabled). Mainly used so the loop
|
||||
* can pin the mode per model: `deepseek-chat` → disabled (legacy
|
||||
* non-thinking compat), everything else → enabled.
|
||||
*/
|
||||
thinking?: "enabled" | "disabled";
|
||||
/**
|
||||
* Per-request reasoning-effort cap. Serialized as the top-level
|
||||
* `reasoning_effort` field. DeepSeek accepts `high` (standard) or
|
||||
* `max` (Agent-class, auto-applied to Claude-Code-style flows per
|
||||
* the V4 docs). Reasonix pins `max` because every turn is agent-like.
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shrinkOversizedToolResultsByTokens } from "../src/loop.js";
|
||||
import {
|
||||
shrinkOversizedToolCallArgsByTokens,
|
||||
shrinkOversizedToolResultsByTokens,
|
||||
} from "../src/loop.js";
|
||||
import { countTokens } from "../src/tokenizer.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
@@ -72,3 +75,105 @@ describe("shrinkOversizedToolResultsByTokens", () => {
|
||||
expect(r.messages[0]!.content).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shrinkOversizedToolCallArgsByTokens", () => {
|
||||
it("leaves small tool-call args alone", () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: '{"path":"src/foo.ts"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const r = shrinkOversizedToolCallArgsByTokens(msgs, 1000);
|
||||
expect(r.healedCount).toBe(0);
|
||||
expect(r.messages).toEqual(msgs);
|
||||
});
|
||||
|
||||
it("shrinks long edit_file search/replace payloads into markers", () => {
|
||||
const longSearch = "old code line\n".repeat(200);
|
||||
const longReplace = "new code line\n".repeat(300);
|
||||
const argsJson = JSON.stringify({
|
||||
path: "src/foo.ts",
|
||||
search: longSearch,
|
||||
replace: longReplace,
|
||||
});
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "c1", type: "function", function: { name: "edit_file", arguments: argsJson } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "c1", content: "applied" },
|
||||
];
|
||||
const r = shrinkOversizedToolCallArgsByTokens(msgs, 500);
|
||||
expect(r.healedCount).toBe(1);
|
||||
expect(r.tokensSaved).toBeGreaterThan(0);
|
||||
expect(r.charsSaved).toBeGreaterThan(0);
|
||||
const a = r.messages[0];
|
||||
if (a?.role !== "assistant" || !a.tool_calls) throw new Error("assistant missing");
|
||||
const shrunkArgs = a.tool_calls[0]!.function.arguments;
|
||||
const parsed = JSON.parse(shrunkArgs) as { path: string; search: string; replace: string };
|
||||
expect(parsed.path).toBe("src/foo.ts");
|
||||
expect(parsed.search).toMatch(/shrunk/);
|
||||
expect(parsed.replace).toMatch(/shrunk/);
|
||||
});
|
||||
|
||||
it("leaves tool results intact (only targets assistant.tool_calls args)", () => {
|
||||
const hugeResult = "log line ".repeat(2000);
|
||||
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "c1", content: hugeResult }];
|
||||
const r = shrinkOversizedToolCallArgsByTokens(msgs, 100);
|
||||
expect(r.healedCount).toBe(0);
|
||||
expect(r.messages[0]!.content).toBe(hugeResult);
|
||||
});
|
||||
|
||||
it("preserves short string fields next to long ones", () => {
|
||||
const longBody = "x".repeat(4000);
|
||||
const argsJson = JSON.stringify({
|
||||
path: "src/foo.ts",
|
||||
content: longBody,
|
||||
mode: "append",
|
||||
});
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "c1", type: "function", function: { name: "write_file", arguments: argsJson } },
|
||||
],
|
||||
},
|
||||
];
|
||||
const r = shrinkOversizedToolCallArgsByTokens(msgs, 500);
|
||||
expect(r.healedCount).toBe(1);
|
||||
const a = r.messages[0];
|
||||
if (a?.role !== "assistant" || !a.tool_calls) throw new Error("assistant missing");
|
||||
const parsed = JSON.parse(a.tool_calls[0]!.function.arguments) as Record<string, unknown>;
|
||||
expect(parsed.path).toBe("src/foo.ts");
|
||||
expect(parsed.mode).toBe("append");
|
||||
expect(String(parsed.content)).toMatch(/shrunk/);
|
||||
});
|
||||
|
||||
it("never mutates the input array or its tool_calls", () => {
|
||||
const bigArgs = JSON.stringify({ path: "a", search: "x".repeat(4000) });
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "c1", type: "function", function: { name: "edit_file", arguments: bigArgs } },
|
||||
],
|
||||
},
|
||||
];
|
||||
const originalArgs = (msgs[0] as any).tool_calls[0].function.arguments;
|
||||
shrinkOversizedToolCallArgsByTokens(msgs, 100);
|
||||
expect((msgs[0] as any).tool_calls[0].function.arguments).toBe(originalArgs);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,18 @@ import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addProjectShellAllowed,
|
||||
editModeHintShown,
|
||||
isPlausibleKey,
|
||||
loadApiKey,
|
||||
loadEditMode,
|
||||
loadProjectShellAllowed,
|
||||
loadReasoningEffort,
|
||||
markEditModeHintShown,
|
||||
readConfig,
|
||||
redactKey,
|
||||
saveApiKey,
|
||||
saveEditMode,
|
||||
saveReasoningEffort,
|
||||
searchEnabled,
|
||||
writeConfig,
|
||||
} from "../src/config.js";
|
||||
@@ -166,4 +172,53 @@ describe("config", () => {
|
||||
addProjectShellAllowed("/a", " ", path);
|
||||
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadEditMode defaults to 'review' when unset", () => {
|
||||
expect(loadEditMode(path)).toBe("review");
|
||||
});
|
||||
|
||||
it("saveEditMode + loadEditMode round-trip 'auto'", () => {
|
||||
saveEditMode("auto", path);
|
||||
expect(loadEditMode(path)).toBe("auto");
|
||||
// Doesn't clobber other fields in the config.
|
||||
expect(readConfig(path).editMode).toBe("auto");
|
||||
});
|
||||
|
||||
it("loadEditMode coerces unknown values back to 'review'", () => {
|
||||
writeConfig({ editMode: "garbage" as any }, path);
|
||||
expect(loadEditMode(path)).toBe("review");
|
||||
});
|
||||
|
||||
it("loadReasoningEffort defaults to 'max' when unset", () => {
|
||||
expect(loadReasoningEffort(path)).toBe("max");
|
||||
});
|
||||
|
||||
it("saveReasoningEffort + loadReasoningEffort round-trip 'high'", () => {
|
||||
saveReasoningEffort("high", path);
|
||||
expect(loadReasoningEffort(path)).toBe("high");
|
||||
expect(readConfig(path).reasoningEffort).toBe("high");
|
||||
});
|
||||
|
||||
it("loadReasoningEffort coerces unknown values back to 'max'", () => {
|
||||
writeConfig({ reasoningEffort: "turbo" as any }, path);
|
||||
expect(loadReasoningEffort(path)).toBe("max");
|
||||
});
|
||||
|
||||
it("saveReasoningEffort doesn't clobber other persisted fields", () => {
|
||||
saveEditMode("auto", path);
|
||||
saveReasoningEffort("high", path);
|
||||
expect(loadEditMode(path)).toBe("auto");
|
||||
expect(loadReasoningEffort(path)).toBe("high");
|
||||
});
|
||||
|
||||
it("editModeHintShown defaults to false and toggles on markEditModeHintShown", () => {
|
||||
expect(editModeHintShown(path)).toBe(false);
|
||||
markEditModeHintShown(path);
|
||||
expect(editModeHintShown(path)).toBe(true);
|
||||
// Idempotent — calling again doesn't rewrite or clobber other fields.
|
||||
saveEditMode("auto", path);
|
||||
markEditModeHintShown(path);
|
||||
expect(editModeHintShown(path)).toBe(true);
|
||||
expect(loadEditMode(path)).toBe("auto");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseEditBlocks,
|
||||
restoreSnapshots,
|
||||
snapshotBeforeEdits,
|
||||
toWholeFileEditBlock,
|
||||
} from "../src/code/edit-blocks.js";
|
||||
|
||||
describe("parseEditBlocks", () => {
|
||||
@@ -237,6 +238,36 @@ describe("snapshotBeforeEdits + restoreSnapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toWholeFileEditBlock", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-whole-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("captures existing content as SEARCH for overwrites", () => {
|
||||
writeFileSync(join(root, "hello.txt"), "old content\n", "utf8");
|
||||
const block = toWholeFileEditBlock("hello.txt", "new content\n", root);
|
||||
expect(block.search).toBe("old content\n");
|
||||
expect(block.replace).toBe("new content\n");
|
||||
// Round-trip: applying this block swaps the whole file.
|
||||
const [res] = applyEditBlocks([block], root);
|
||||
expect(res!.status).toBe("applied");
|
||||
expect(readFileSync(join(root, "hello.txt"), "utf8")).toBe("new content\n");
|
||||
});
|
||||
|
||||
it("leaves SEARCH empty when the file doesn't exist (create-new sentinel)", () => {
|
||||
const block = toWholeFileEditBlock("new.txt", "fresh\n", root);
|
||||
expect(block.search).toBe("");
|
||||
expect(block.replace).toBe("fresh\n");
|
||||
const [res] = applyEditBlocks([block], root);
|
||||
expect(res!.status).toBe("created");
|
||||
expect(readFileSync(join(root, "new.txt"), "utf8")).toBe("fresh\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEditBlocks (batch)", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -33,7 +33,11 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
|
||||
it("honors head=N to return only the first N lines", async () => {
|
||||
const out = await tools.dispatch("read_file", JSON.stringify({ path: "hello.txt", head: 2 }));
|
||||
expect(out).toBe("line 1\nline 2");
|
||||
// Head output now includes an "N of M lines" marker so the model
|
||||
// knows it didn't get the whole file. The actual content still
|
||||
// leads the string, un-escaped.
|
||||
expect(out).toMatch(/^line 1\nline 2/);
|
||||
expect(out).toMatch(/head 2 of 3 lines/);
|
||||
});
|
||||
|
||||
it("honors tail=N", async () => {
|
||||
@@ -41,6 +45,56 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
expect(out).toContain("line 2");
|
||||
expect(out).toContain("line 3");
|
||||
expect(out).not.toContain("line 1");
|
||||
expect(out).toMatch(/tail 2 of 3 lines/);
|
||||
});
|
||||
|
||||
it("range='A-B' returns the inclusive line range", async () => {
|
||||
// Write a bigger file so the range slice is distinguishable from
|
||||
// the head/tail paths and the auto-preview cutover.
|
||||
await fs.writeFile(
|
||||
join(root, "big.txt"),
|
||||
Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n"),
|
||||
);
|
||||
const out = await tools.dispatch(
|
||||
"read_file",
|
||||
JSON.stringify({ path: "big.txt", range: "3-5" }),
|
||||
);
|
||||
expect(out).toMatch(/\[range 3-5 of 10 lines\]/);
|
||||
expect(out).toContain("line 3");
|
||||
expect(out).toContain("line 5");
|
||||
expect(out).not.toContain("line 2");
|
||||
expect(out).not.toContain("line 6");
|
||||
});
|
||||
|
||||
it("range clamps out-of-range values to the file bounds", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"read_file",
|
||||
JSON.stringify({ path: "hello.txt", range: "2-99" }),
|
||||
);
|
||||
expect(out).toMatch(/\[range 2-3 of 3 lines\]/);
|
||||
expect(out).toContain("line 2");
|
||||
expect(out).toContain("line 3");
|
||||
});
|
||||
|
||||
it("auto-previews large files when no scope is given", async () => {
|
||||
// File larger than DEFAULT_AUTO_PREVIEW_LINES (200) triggers the
|
||||
// head+tail preview + omitted-lines marker.
|
||||
const bigLines = Array.from({ length: 250 }, (_, i) => `line ${i + 1}`);
|
||||
await fs.writeFile(join(root, "huge.txt"), bigLines.join("\n"));
|
||||
const out = await tools.dispatch("read_file", JSON.stringify({ path: "huge.txt" }));
|
||||
expect(out).toMatch(/auto-preview: head 80 \+ tail 40 of 250 lines/);
|
||||
expect(out).toMatch(/130 lines omitted/);
|
||||
expect(out).toContain("line 1");
|
||||
expect(out).toContain("line 80");
|
||||
expect(out).toContain("line 211");
|
||||
expect(out).toContain("line 250");
|
||||
expect(out).not.toContain("line 100");
|
||||
expect(out).not.toContain("line 200");
|
||||
});
|
||||
|
||||
it("returns full content when file is at or below the auto-preview threshold", async () => {
|
||||
const out = await tools.dispatch("read_file", JSON.stringify({ path: "hello.txt" }));
|
||||
expect(out).toBe("line 1\nline 2\nline 3");
|
||||
});
|
||||
|
||||
it("rejects paths outside the sandbox root", async () => {
|
||||
@@ -97,6 +151,58 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
expect(out).toContain("src/");
|
||||
expect(out).not.toContain("index.ts");
|
||||
});
|
||||
|
||||
it("directory_tree skips node_modules / .git / dist by default", async () => {
|
||||
await fs.mkdir(join(root, "node_modules", "foo"), { recursive: true });
|
||||
await fs.writeFile(join(root, "node_modules", "foo", "dep.js"), "// dep\n");
|
||||
await fs.mkdir(join(root, ".git"), { recursive: true });
|
||||
await fs.writeFile(join(root, ".git", "HEAD"), "ref\n");
|
||||
await fs.mkdir(join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(join(root, "dist", "out.js"), "// out\n");
|
||||
const out = await tools.dispatch("directory_tree", JSON.stringify({ path: "." }));
|
||||
// Skip markers show the dir exists but don't walk into it.
|
||||
expect(out).toMatch(/node_modules\/\s+\(skipped/);
|
||||
expect(out).toMatch(/\.git\/\s+\(skipped/);
|
||||
expect(out).toMatch(/dist\/\s+\(skipped/);
|
||||
expect(out).not.toContain("dep.js");
|
||||
expect(out).not.toContain("HEAD");
|
||||
expect(out).not.toContain("out.js");
|
||||
});
|
||||
|
||||
it("directory_tree traverses deps when include_deps:true", async () => {
|
||||
await fs.mkdir(join(root, "node_modules", "foo"), { recursive: true });
|
||||
await fs.writeFile(join(root, "node_modules", "foo", "dep.js"), "// dep\n");
|
||||
const out = await tools.dispatch(
|
||||
"directory_tree",
|
||||
JSON.stringify({ path: ".", include_deps: true, maxDepth: 3 }),
|
||||
);
|
||||
expect(out).toContain("dep.js");
|
||||
expect(out).not.toMatch(/skipped/);
|
||||
});
|
||||
|
||||
it("directory_tree default maxDepth is 2", async () => {
|
||||
await fs.mkdir(join(root, "a", "b", "c"), { recursive: true });
|
||||
await fs.writeFile(join(root, "a", "b", "c", "deep.txt"), "x\n");
|
||||
await fs.writeFile(join(root, "a", "b", "shallow.txt"), "y\n");
|
||||
const out = await tools.dispatch("directory_tree", JSON.stringify({ path: "." }));
|
||||
// depth 2 shows a/, a/b/, a/b/shallow.txt — but NOT a/b/c's children.
|
||||
expect(out).toContain("shallow.txt");
|
||||
expect(out).toContain("c/");
|
||||
expect(out).not.toContain("deep.txt");
|
||||
});
|
||||
|
||||
it("directory_tree collapses directories with >50 entries", async () => {
|
||||
await fs.mkdir(join(root, "huge"), { recursive: true });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await fs.writeFile(join(root, "huge", `f${String(i).padStart(3, "0")}.txt`), "x");
|
||||
}
|
||||
const out = await tools.dispatch(
|
||||
"directory_tree",
|
||||
JSON.stringify({ path: "huge", maxDepth: 1 }),
|
||||
);
|
||||
expect(out).toMatch(/\[… \d+ entries hidden/);
|
||||
expect(out).toMatch(/list_directory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_files", () => {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* JobRegistry — background-process lifecycle. Each test uses a real
|
||||
* child process via `node -e "..."` so we actually exercise the spawn/
|
||||
* pipe/kill plumbing. Inline scripts keep fixtures self-contained; no
|
||||
* temp files to clean up.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { JobRegistry } from "../src/tools/jobs.js";
|
||||
|
||||
describe("JobRegistry", () => {
|
||||
let cwd: string;
|
||||
let registry: JobRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), "reasonix-job-"));
|
||||
registry = new JobRegistry();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await registry.shutdown(1500);
|
||||
// Windows occasionally hangs on to the cwd handle for a few ms
|
||||
// after the child exits; a retry-with-delay catches that without
|
||||
// failing the suite when cleanup is a lost cause.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
break;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns after waitSec for a process that stays alive", async () => {
|
||||
// Long-lived child that prints a line and sleeps 10s. We'll return
|
||||
// after waitSec=1 while it's still running.
|
||||
const cmd = `node -e "console.log('hi'); setTimeout(() => {}, 10000)"`;
|
||||
const t0 = Date.now();
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 1 });
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(res.stillRunning).toBe(true);
|
||||
expect(res.preview).toContain("hi");
|
||||
expect(elapsed).toBeGreaterThanOrEqual(900);
|
||||
// readiness pattern may or may not match "hi" depending on env;
|
||||
// the test's primary claim is "we came back without waiting 10s".
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it("short-circuits on ready signal before waitSec elapses", async () => {
|
||||
// Print a known ready banner immediately; waitSec=5 should be
|
||||
// cut short when the regex fires.
|
||||
const cmd = `node -e "console.log('listening on http://localhost:1234'); setTimeout(() => {}, 10000)"`;
|
||||
const t0 = Date.now();
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 5 });
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(res.stillRunning).toBe(true);
|
||||
expect(res.readyMatched).toBe(true);
|
||||
// Must be well under the 5s ceiling — startup + ready-regex match
|
||||
// should land in a few hundred ms at most.
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it("captures exit code when the child dies during wait", async () => {
|
||||
const cmd = `node -e "console.log('bye'); process.exit(7)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 3 });
|
||||
expect(res.stillRunning).toBe(false);
|
||||
expect(res.exitCode).toBe(7);
|
||||
expect(res.preview).toContain("bye");
|
||||
});
|
||||
|
||||
it("read(jobId) returns live output and running flag", async () => {
|
||||
const cmd = `node -e "console.log('a'); setTimeout(() => console.log('b'), 80); setTimeout(() => {}, 10000)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 0.3 });
|
||||
const early = registry.read(res.jobId);
|
||||
expect(early?.running).toBe(true);
|
||||
expect(early?.output).toContain("a");
|
||||
// Wait for the second print, then re-read.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const later = registry.read(res.jobId);
|
||||
expect(later?.output).toContain("b");
|
||||
expect((later?.byteLength ?? 0) >= (early?.byteLength ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("read supports `since` for incremental polling", async () => {
|
||||
// `first` prints synchronously at child startup so snap catches it
|
||||
// reliably; `second` is delayed well past the waitSec ceiling so it
|
||||
// arrives AFTER the snapshot, guaranteeing the `since`-slice actually
|
||||
// has new bytes to return.
|
||||
const cmd = `node -e "console.log('first'); setTimeout(()=>console.log('second'), 1500); setTimeout(()=>{}, 10000)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 0.6 });
|
||||
// Poll briefly in case Windows node startup is slow — we need
|
||||
// `first` in the buffer before capturing the cursor, otherwise the
|
||||
// whole premise of the test falls apart.
|
||||
let snap = registry.read(res.jobId);
|
||||
for (let i = 0; i < 10 && !snap?.output.includes("first"); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
snap = registry.read(res.jobId);
|
||||
}
|
||||
expect(snap?.output).toContain("first");
|
||||
expect(snap?.output).not.toContain("second");
|
||||
const cursor = snap?.byteLength ?? 0;
|
||||
expect(cursor).toBeGreaterThan(0);
|
||||
// Wait past the delayed print so we have new content.
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
const delta = registry.read(res.jobId, { since: cursor });
|
||||
expect(delta?.output).not.toContain("first");
|
||||
expect(delta?.output).toContain("second");
|
||||
});
|
||||
|
||||
it("tailLines caps returned output to the last N lines", async () => {
|
||||
const cmd = `node -e "for (let i=0;i<50;i++) console.log('line'+i); setTimeout(()=>{}, 10000)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 0.5 });
|
||||
const tailed = registry.read(res.jobId, { tailLines: 5 });
|
||||
const lines = (tailed?.output ?? "").split("\n").filter(Boolean);
|
||||
expect(lines.length).toBeLessThanOrEqual(5);
|
||||
expect(tailed?.output).toContain("line49");
|
||||
expect(tailed?.output).not.toContain("line0\n");
|
||||
});
|
||||
|
||||
it("stop() kills a running job", async () => {
|
||||
const cmd = `node -e "setTimeout(()=>{}, 10000)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 0.2 });
|
||||
expect(res.stillRunning).toBe(true);
|
||||
const rec = await registry.stop(res.jobId, { graceMs: 200 });
|
||||
expect(rec?.running).toBe(false);
|
||||
});
|
||||
|
||||
it("stop() on an already-exited job is idempotent", async () => {
|
||||
const cmd = `node -e "process.exit(0)"`;
|
||||
const res = await registry.start(cmd, { cwd, waitSec: 2 });
|
||||
expect(res.stillRunning).toBe(false);
|
||||
const rec = await registry.stop(res.jobId);
|
||||
expect(rec?.running).toBe(false);
|
||||
expect(rec?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("list() reports running and exited jobs together", async () => {
|
||||
const running = await registry.start(`node -e "setTimeout(()=>{}, 10000)"`, {
|
||||
cwd,
|
||||
waitSec: 0.2,
|
||||
});
|
||||
const exited = await registry.start(`node -e "process.exit(0)"`, { cwd, waitSec: 2 });
|
||||
const rows = registry.list();
|
||||
expect(rows).toHaveLength(2);
|
||||
const r1 = rows.find((r) => r.id === running.jobId);
|
||||
const r2 = rows.find((r) => r.id === exited.jobId);
|
||||
expect(r1?.running).toBe(true);
|
||||
expect(r2?.running).toBe(false);
|
||||
expect(r2?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("runningCount() reflects live state", async () => {
|
||||
expect(registry.runningCount()).toBe(0);
|
||||
const a = await registry.start(`node -e "setTimeout(()=>{}, 10000)"`, { cwd, waitSec: 0.2 });
|
||||
expect(registry.runningCount()).toBe(1);
|
||||
await registry.stop(a.jobId);
|
||||
expect(registry.runningCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects shell operators with a clear message", async () => {
|
||||
await expect(registry.start("echo hi | grep h", { cwd })).rejects.toThrow(/shell operator/);
|
||||
});
|
||||
|
||||
it("read() returns null for unknown job id", () => {
|
||||
expect(registry.read(999)).toBeNull();
|
||||
});
|
||||
|
||||
it("shutdown() kills all running jobs", async () => {
|
||||
await registry.start(`node -e "setTimeout(()=>{}, 10000)"`, { cwd, waitSec: 0.2 });
|
||||
await registry.start(`node -e "setTimeout(()=>{}, 10000)"`, { cwd, waitSec: 0.2 });
|
||||
expect(registry.runningCount()).toBe(2);
|
||||
// 4s deadline: Windows taskkill /T is async and needs ~500-800ms
|
||||
// per process to propagate through the tree + reap confirmation.
|
||||
await registry.shutdown(4000);
|
||||
expect(registry.runningCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,48 @@ describe("formatLoopError", () => {
|
||||
expect(out).toMatch(/929,452 tokens/); // pretty-printed from the raw JSON
|
||||
});
|
||||
|
||||
it("leaves non-overflow errors unchanged", () => {
|
||||
const raw = new Error("DeepSeek 401: invalid api key");
|
||||
expect(formatLoopError(raw)).toBe("DeepSeek 401: invalid api key");
|
||||
it("401 → authentication hint with setup/env var fix", () => {
|
||||
const raw = new Error(
|
||||
'DeepSeek 401: {"error":{"message":"Authentication Fails, Your api key is invalid"}}',
|
||||
);
|
||||
const out = formatLoopError(raw);
|
||||
expect(out).toMatch(/Authentication failed/);
|
||||
expect(out).toMatch(/reasonix setup/);
|
||||
expect(out).toMatch(/DEEPSEEK_API_KEY/);
|
||||
// Inner error.message survives the unwrap
|
||||
expect(out).toContain("Your api key is invalid");
|
||||
});
|
||||
|
||||
it("402 → balance hint with top-up URL", () => {
|
||||
const raw = new Error('DeepSeek 402: {"error":{"message":"Insufficient Balance"}}');
|
||||
const out = formatLoopError(raw);
|
||||
expect(out).toMatch(/Out of balance/);
|
||||
expect(out).toMatch(/top_up/);
|
||||
expect(out).toContain("Insufficient Balance");
|
||||
});
|
||||
|
||||
it("422 → invalid parameter with the server's reason", () => {
|
||||
const raw = new Error(
|
||||
'DeepSeek 422: {"error":{"message":"Invalid value for `temperature`: must be between 0 and 2"}}',
|
||||
);
|
||||
const out = formatLoopError(raw);
|
||||
expect(out).toMatch(/Invalid parameter/);
|
||||
expect(out).toContain("temperature");
|
||||
});
|
||||
|
||||
it("400 (non-overflow) → extracts the inner error message, drops the JSON wrapping", () => {
|
||||
const raw = new Error(
|
||||
'DeepSeek 400: {"error":{"message":"request body malformed at messages[3].role"}}',
|
||||
);
|
||||
const out = formatLoopError(raw);
|
||||
expect(out).toMatch(/Bad request/);
|
||||
expect(out).toContain("messages[3].role");
|
||||
expect(out).not.toContain("{"); // JSON wrapping is gone
|
||||
});
|
||||
|
||||
it("leaves non-DeepSeek-shaped errors untouched", () => {
|
||||
const raw = new Error("socket hang up");
|
||||
expect(formatLoopError(raw)).toBe("socket hang up");
|
||||
});
|
||||
|
||||
it("tolerates an overflow error without a requested-tokens figure", () => {
|
||||
@@ -32,6 +71,22 @@ describe("formatLoopError", () => {
|
||||
expect(out).toMatch(/Context overflow/);
|
||||
expect(out).toMatch(/too many tokens/);
|
||||
});
|
||||
|
||||
it("context-overflow message mentions both the 1M V4 limit and the legacy 131k", () => {
|
||||
const raw = new Error(
|
||||
'DeepSeek 400: {"error":{"message":"This model\'s maximum context length is 131072 tokens. However, you requested 200000 tokens."}}',
|
||||
);
|
||||
const out = formatLoopError(raw);
|
||||
expect(out).toMatch(/1M/);
|
||||
expect(out).toMatch(/131k/);
|
||||
});
|
||||
|
||||
it("tolerates an empty or malformed JSON body on the error payload", () => {
|
||||
const raw = new Error("DeepSeek 500: ");
|
||||
const out = formatLoopError(raw);
|
||||
// 500 isn't in the remapped set, so it passes through as-is
|
||||
expect(out).toBe("DeepSeek 500: ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("healLoadedMessages", () => {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* R1 thinking-mode contract: when a reasoner turn returns both
|
||||
* `reasoning_content` and `tool_calls`, the assistant message we
|
||||
* persist + send on the NEXT request (the tool-loop continuation)
|
||||
* must carry `reasoning_content` back. Otherwise DeepSeek 400s with
|
||||
* "The reasoning_content in the thinking mode must be passed back to
|
||||
* the API." Reproduces the bug that surfaced in 0.5.14 live usage.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop, isThinkingModeModel, thinkingModeForModel } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
describe("isThinkingModeModel", () => {
|
||||
it("deepseek-reasoner → true (legacy R1 alias)", () => {
|
||||
expect(isThinkingModeModel("deepseek-reasoner")).toBe(true);
|
||||
});
|
||||
it("deepseek-v4-flash → true (default thinking)", () => {
|
||||
expect(isThinkingModeModel("deepseek-v4-flash")).toBe(true);
|
||||
});
|
||||
it("deepseek-v4-pro → true (default thinking)", () => {
|
||||
expect(isThinkingModeModel("deepseek-v4-pro")).toBe(true);
|
||||
});
|
||||
it("deepseek-chat → false (non-thinking compat alias)", () => {
|
||||
expect(isThinkingModeModel("deepseek-chat")).toBe(false);
|
||||
});
|
||||
it("unknown models → false (safe default)", () => {
|
||||
expect(isThinkingModeModel("gpt-4")).toBe(false);
|
||||
expect(isThinkingModeModel("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingModeForModel", () => {
|
||||
it("chat → disabled, reasoner → enabled (compat aliases pin the mode)", () => {
|
||||
expect(thinkingModeForModel("deepseek-chat")).toBe("disabled");
|
||||
expect(thinkingModeForModel("deepseek-reasoner")).toBe("enabled");
|
||||
});
|
||||
it("v4 models → enabled (docs default)", () => {
|
||||
expect(thinkingModeForModel("deepseek-v4-flash")).toBe("enabled");
|
||||
expect(thinkingModeForModel("deepseek-v4-pro")).toBe("enabled");
|
||||
});
|
||||
it("unknown models → undefined (let server decide)", () => {
|
||||
expect(thinkingModeForModel("gpt-4")).toBeUndefined();
|
||||
expect(thinkingModeForModel("anthropic-claude")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeResponseShape {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: any[];
|
||||
usage?: Record<string, number>;
|
||||
}
|
||||
|
||||
function capturingFetch(responses: FakeResponseShape[]): {
|
||||
fetch: typeof fetch;
|
||||
bodies: Array<{
|
||||
messages: ChatMessage[];
|
||||
extra_body?: { thinking?: { type?: string } };
|
||||
reasoning_effort?: string;
|
||||
}>;
|
||||
} {
|
||||
const bodies: Array<{
|
||||
messages: ChatMessage[];
|
||||
extra_body?: { thinking?: { type?: string } };
|
||||
reasoning_effort?: string;
|
||||
}> = [];
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (_url: any, init: any) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : {};
|
||||
bodies.push({
|
||||
messages: body.messages,
|
||||
extra_body: body.extra_body,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
});
|
||||
const resp = responses[i++] ?? responses[responses.length - 1]!;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: resp.content ?? "",
|
||||
reasoning_content: resp.reasoning_content ?? null,
|
||||
tool_calls: resp.tool_calls ?? undefined,
|
||||
},
|
||||
finish_reason: resp.tool_calls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: resp.usage ?? {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_cache_hit_tokens: 0,
|
||||
prompt_cache_miss_tokens: 100,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetch: fn, bodies };
|
||||
}
|
||||
|
||||
describe("R1 reasoning_content round-trip", () => {
|
||||
it("preserves reasoning_content on the assistant message when the turn has tool_calls", async () => {
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "noop",
|
||||
readOnly: true,
|
||||
fn: () => "ok",
|
||||
});
|
||||
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([
|
||||
{
|
||||
// Turn 1: model emits reasoning + tool call.
|
||||
content: "",
|
||||
reasoning_content: "I should call noop to check something.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "noop", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Turn 2: plain text wrap-up after the tool result comes back.
|
||||
content: "done",
|
||||
},
|
||||
]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
model: "deepseek-reasoner",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("please noop")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(bodies.length).toBe(2);
|
||||
// Turn 2's request messages include the turn-1 assistant message;
|
||||
// find it and verify reasoning_content landed.
|
||||
const turn2Messages = bodies[1]!.messages;
|
||||
const assistantWithCalls = turn2Messages.find(
|
||||
(m) => m.role === "assistant" && (m.tool_calls?.length ?? 0) > 0,
|
||||
);
|
||||
expect(assistantWithCalls).toBeDefined();
|
||||
expect(assistantWithCalls?.reasoning_content).toBe("I should call noop to check something.");
|
||||
});
|
||||
|
||||
it("also preserves reasoning_content on plain-text reasoner turns", async () => {
|
||||
// 0.5.18 regression: R1 requires reasoning_content on ANY
|
||||
// assistant message the model produced in thinking mode, not just
|
||||
// ones with tool_calls. 0.5.15 scoped the fix too narrowly and a
|
||||
// plan-approval flow (submit_plan → "plan submitted" plain-text
|
||||
// turn → approval) kept 400ing on the follow-up request.
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([
|
||||
{
|
||||
content: "a plain answer",
|
||||
reasoning_content: "reasoning attached to a plain-text turn",
|
||||
},
|
||||
{ content: "follow-up" },
|
||||
]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-reasoner",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
for await (const _ev of loop.step("next")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
const turn2Messages = bodies[1]!.messages;
|
||||
const assistant = turn2Messages.find((m) => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(assistant?.reasoning_content).toBe("reasoning attached to a plain-text turn");
|
||||
});
|
||||
|
||||
it("pins thinking=enabled + reasoning_effort=max for v4-pro (agent-class default)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-v4-pro",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body?.thinking?.type).toBe("enabled");
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
|
||||
it("pins thinking=disabled for deepseek-chat (non-thinking compat alias)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-chat",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body?.thinking?.type).toBe("disabled");
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
|
||||
it("omits thinking entirely for unknown models (let the server decide)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "some-third-party-model",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body).toBeUndefined();
|
||||
// reasoning_effort is always set — it's a benign field for models
|
||||
// that don't know it (OpenAI just ignores unknown top-level fields).
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
});
|
||||
+126
-17
@@ -102,7 +102,10 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
await loop.run("q");
|
||||
expect(loop.stats.aggregateCacheHitRatio).toBeCloseTo(0.8);
|
||||
expect(loop.stats.totalCost).toBeGreaterThan(0);
|
||||
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.9);
|
||||
// Savings vs Claude depends on which DeepSeek model is the loop's
|
||||
// default. v4-pro lands around 0.85; v4-flash around 0.97. Test the
|
||||
// lower bound so a future default swap doesn't churn this assertion.
|
||||
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it("dispatches a tool call and loops until the model stops", async () => {
|
||||
@@ -386,18 +389,18 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
// First response: chaining tool call with a prompt-token count
|
||||
// deliberately over 80% of DeepSeek's 131k window (131k * 0.8 = 104_857).
|
||||
// 120k trips the guard.
|
||||
// deliberately over 80% of DeepSeek V4's 1M window (1M * 0.8 =
|
||||
// 800k). 900k trips the guard.
|
||||
const responses: FakeResponseShape[] = [
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 120_000,
|
||||
prompt_tokens: 900_000,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 120_050,
|
||||
prompt_cache_hit_tokens: 90_000,
|
||||
prompt_cache_miss_tokens: 30_000,
|
||||
total_tokens: 900_050,
|
||||
prompt_cache_hit_tokens: 700_000,
|
||||
prompt_cache_miss_tokens: 200_000,
|
||||
},
|
||||
},
|
||||
// Forced-summary response (no tools)
|
||||
@@ -446,16 +449,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
// Iter 0: chains a tool call, usage trips the guard.
|
||||
// Iter 0: chains a tool call, usage trips the 80% guard against
|
||||
// V4's 1M window.
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 120_000,
|
||||
prompt_tokens: 900_000,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 120_010,
|
||||
prompt_cache_hit_tokens: 100_000,
|
||||
prompt_cache_miss_tokens: 20_000,
|
||||
total_tokens: 900_010,
|
||||
prompt_cache_hit_tokens: 750_000,
|
||||
prompt_cache_miss_tokens: 150_000,
|
||||
},
|
||||
},
|
||||
// Iter 1: model wraps up normally after the tool result.
|
||||
@@ -505,16 +509,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
// 131k * 0.72 ≈ 94,320 tokens — squarely in the proactive band.
|
||||
// V4's 1M window × 0.72 ≈ 720k tokens — squarely in the 60–80%
|
||||
// proactive band without tripping the reactive 80% guard.
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 95_000,
|
||||
prompt_tokens: 720_000,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 95_010,
|
||||
prompt_cache_hit_tokens: 70_000,
|
||||
prompt_cache_miss_tokens: 25_000,
|
||||
total_tokens: 720_010,
|
||||
prompt_cache_hit_tokens: 550_000,
|
||||
prompt_cache_miss_tokens: 170_000,
|
||||
},
|
||||
},
|
||||
{ content: "done." },
|
||||
@@ -643,6 +648,110 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CacheFirstLoop — self-reported escalation via <<<NEEDS_PRO>>>", () => {
|
||||
function modelCapturingFetch(responses: FakeResponseShape[]): {
|
||||
fetch: typeof fetch;
|
||||
seenModels: string[];
|
||||
} {
|
||||
const seenModels: string[] = [];
|
||||
let i = 0;
|
||||
const fetchFn = vi.fn(async (_url: any, init: any) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : {};
|
||||
seenModels.push(body.model);
|
||||
const resp = responses[i++] ?? responses[responses.length - 1]!;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: resp.content ?? "",
|
||||
reasoning_content: resp.reasoning_content ?? null,
|
||||
tool_calls: resp.tool_calls ?? undefined,
|
||||
},
|
||||
finish_reason: resp.tool_calls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: resp.usage ?? {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_cache_hit_tokens: 0,
|
||||
prompt_cache_miss_tokens: 100,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetch: fetchFn, seenModels };
|
||||
}
|
||||
|
||||
it("retries on v4-pro when flash outputs the NEEDS_PRO marker as lead-in", async () => {
|
||||
const { fetch, seenModels } = modelCapturingFetch([
|
||||
{ content: "<<<NEEDS_PRO>>>" }, // first call on flash → escalation request
|
||||
{ content: "OK, here's the answer on pro." }, // retry on pro → real response
|
||||
]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client: new DeepSeekClient({ apiKey: "sk-test", fetch }),
|
||||
prefix: new ImmutablePrefix({ system: "be brief" }),
|
||||
model: "deepseek-v4-flash",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const events: { role: string; content?: string }[] = [];
|
||||
for await (const ev of loop.step("hard question")) {
|
||||
events.push({ role: ev.role, content: ev.content });
|
||||
}
|
||||
|
||||
// Two model calls total: first flash, second pro
|
||||
expect(seenModels).toEqual(["deepseek-v4-flash", "deepseek-v4-pro"]);
|
||||
// A warning surfaced about the retry
|
||||
expect(events.some((e) => e.role === "warning" && /escalat/i.test(e.content ?? ""))).toBe(true);
|
||||
// The final assistant message is the pro-generated content, not the marker
|
||||
const finalEv = events.find((e) => e.role === "assistant_final");
|
||||
expect(finalEv?.content).toBe("OK, here's the answer on pro.");
|
||||
});
|
||||
|
||||
it("does not retry when the response merely mentions the marker mid-text", async () => {
|
||||
const { fetch, seenModels } = modelCapturingFetch([
|
||||
{ content: "See the docs: <<<NEEDS_PRO>>> is a reserved marker." },
|
||||
]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client: new DeepSeekClient({ apiKey: "sk-test", fetch }),
|
||||
prefix: new ImmutablePrefix({ system: "be brief" }),
|
||||
model: "deepseek-v4-flash",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("explain the marker")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(seenModels).toEqual(["deepseek-v4-flash"]);
|
||||
});
|
||||
|
||||
it("does not escalate again when the model is already on pro", async () => {
|
||||
// Even if pro happens to echo the marker, no infinite-retry loop.
|
||||
const { fetch, seenModels } = modelCapturingFetch([
|
||||
{ content: "<<<NEEDS_PRO>>>" }, // on pro — should NOT trigger retry
|
||||
]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client: new DeepSeekClient({ apiKey: "sk-test", fetch }),
|
||||
prefix: new ImmutablePrefix({ system: "be brief" }),
|
||||
model: "deepseek-v4-pro",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("hi")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
// Exactly one call; no retry.
|
||||
expect(seenModels).toEqual(["deepseek-v4-pro"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CacheFirstLoop (streaming) — tool_call_delta emission", () => {
|
||||
it("yields tool_call_delta events carrying growing arg-char count", async () => {
|
||||
const client = new DeepSeekClient({
|
||||
|
||||
@@ -504,6 +504,21 @@ describe("citation links — validateCitation + collectCitations", () => {
|
||||
if (!result.ok) expect(result.reason).toBe("file not found");
|
||||
});
|
||||
|
||||
it("leading `/` is treated as project-rooted, not filesystem-absolute", () => {
|
||||
// Models habitually write `/foo.ts` meaning "project root" (Aider /
|
||||
// Claude-Code style). Without the strip this would be resolved
|
||||
// against POSIX `/` or the Windows drive root and always fail —
|
||||
// that was the symptom in 0.5.16: a real file at the project root
|
||||
// rendered as "file not found" because the leading slash sent the
|
||||
// validator to the wrong place.
|
||||
expect(validateCitation("/real.ts", tmp)).toEqual({ ok: true });
|
||||
expect(validateCitation("/real.ts:2", tmp)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("leading `\\` is treated the same way (Windows-style)", () => {
|
||||
expect(validateCitation("\\real.ts", tmp)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("collectCitations validates every unique citation, skips externals", () => {
|
||||
const text = [
|
||||
"See [foo](real.ts:2) for details.",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampBodyByLines } from "../src/cli/ui/PlanConfirm.js";
|
||||
|
||||
describe("clampBodyByLines", () => {
|
||||
it("returns text unchanged when already within the row budget", () => {
|
||||
const text = "line 1\nline 2\nline 3";
|
||||
expect(clampBodyByLines(text, 10)).toBe(text);
|
||||
});
|
||||
|
||||
it("trims to the first N lines and appends a truncation marker", () => {
|
||||
const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
|
||||
const out = clampBodyByLines(lines.join("\n"), 10);
|
||||
expect(out.split("\n").slice(0, 10)).toEqual(lines.slice(0, 10));
|
||||
expect(out).toContain("30 more lines truncated");
|
||||
});
|
||||
|
||||
it("shows the dropped-line count for the truncation hint", () => {
|
||||
const text = Array.from({ length: 25 }, (_, i) => `row${i}`).join("\n");
|
||||
expect(clampBodyByLines(text, 5)).toContain("20 more lines truncated");
|
||||
});
|
||||
|
||||
it("handles an empty body without throwing", () => {
|
||||
expect(clampBodyByLines("", 5)).toBe("");
|
||||
});
|
||||
|
||||
it("does not trim when exactly at the budget", () => {
|
||||
const text = "a\nb\nc";
|
||||
expect(clampBodyByLines(text, 3)).toBe(text);
|
||||
});
|
||||
});
|
||||
+22
-15
@@ -42,36 +42,42 @@ describe("resolveDefaults", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("empty flags + empty config → hard-coded fast preset", () => {
|
||||
it("empty flags + empty config → hard-coded smart preset (flash + max)", () => {
|
||||
const r = resolveDefaults({});
|
||||
expect(r.model).toBe("deepseek-chat");
|
||||
expect(r.model).toBe("deepseek-v4-flash");
|
||||
expect(r.reasoningEffort).toBe("max");
|
||||
expect(r.harvest).toBe(false);
|
||||
expect(r.branch).toBeUndefined();
|
||||
expect(r.mcp).toEqual([]);
|
||||
expect(r.session).toBe("default");
|
||||
});
|
||||
|
||||
it("config.preset 'smart' flips harvest on and switches model", () => {
|
||||
writeConfig({ preset: "smart" }, join(home, ".reasonix", "config.json"));
|
||||
it("config.preset 'fast' drops effort to high (still flash, no harvest)", () => {
|
||||
writeConfig({ preset: "fast" }, join(home, ".reasonix", "config.json"));
|
||||
const r = resolveDefaults({});
|
||||
expect(r.model).toBe("deepseek-reasoner");
|
||||
expect(r.harvest).toBe(true);
|
||||
expect(r.model).toBe("deepseek-v4-flash");
|
||||
expect(r.reasoningEffort).toBe("high");
|
||||
expect(r.harvest).toBe(false);
|
||||
expect(r.branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--preset max overrides config.preset=fast", () => {
|
||||
it("--preset max overrides config.preset=fast → pro + max, no branch", () => {
|
||||
writeConfig({ preset: "fast" }, join(home, ".reasonix", "config.json"));
|
||||
const r = resolveDefaults({ preset: "max" });
|
||||
expect(r.model).toBe("deepseek-reasoner");
|
||||
expect(r.branch).toBe(3);
|
||||
expect(r.model).toBe("deepseek-v4-pro");
|
||||
expect(r.reasoningEffort).toBe("max");
|
||||
expect(r.harvest).toBe(false);
|
||||
// branch is NEVER part of a preset — only /branch or --branch turns it on.
|
||||
expect(r.branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--model wins even when --preset is set", () => {
|
||||
const r = resolveDefaults({ preset: "max", model: "deepseek-chat" });
|
||||
expect(r.model).toBe("deepseek-chat");
|
||||
// preset still controls harvest/branch
|
||||
expect(r.harvest).toBe(true);
|
||||
expect(r.branch).toBe(3);
|
||||
const r = resolveDefaults({ preset: "max", model: "deepseek-v4-flash" });
|
||||
expect(r.model).toBe("deepseek-v4-flash");
|
||||
// preset still controls effort/harvest/branch
|
||||
expect(r.reasoningEffort).toBe("max");
|
||||
expect(r.harvest).toBe(false);
|
||||
expect(r.branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--mcp overrides config.mcp wholesale (no merging)", () => {
|
||||
@@ -96,7 +102,8 @@ describe("resolveDefaults", () => {
|
||||
it("--no-config ignores the config entirely", () => {
|
||||
writeConfig({ preset: "max", mcp: ["x=cmd"] }, join(home, ".reasonix", "config.json"));
|
||||
const r = resolveDefaults({ noConfig: true });
|
||||
expect(r.model).toBe("deepseek-chat"); // fast defaults
|
||||
expect(r.model).toBe("deepseek-v4-flash"); // smart defaults (new default)
|
||||
expect(r.reasoningEffort).toBe("max");
|
||||
expect(r.mcp).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -239,10 +239,17 @@ describe("registerShellTools — dispatch integration", () => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers run_command", () => {
|
||||
it("registers run_command + background tools", () => {
|
||||
const registry = new ToolRegistry();
|
||||
registerShellTools(registry, { rootDir: tmp });
|
||||
expect(registry.size).toBe(1);
|
||||
// run_command (sync) + run_background / job_output / stop_job /
|
||||
// list_jobs (background family).
|
||||
expect(registry.size).toBe(5);
|
||||
expect(registry.has("run_command")).toBe(true);
|
||||
expect(registry.has("run_background")).toBe(true);
|
||||
expect(registry.has("job_output")).toBe(true);
|
||||
expect(registry.has("stop_job")).toBe(true);
|
||||
expect(registry.has("list_jobs")).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-runs allowlisted commands and returns formatted output", async () => {
|
||||
|
||||
+10
-5
@@ -221,7 +221,7 @@ describe("applySkillsIndex", () => {
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("marks subagent-runAs skills with the 🧬 prefix in the index", () => {
|
||||
it("tags subagent-runAs skills AFTER the name in the index (not before)", () => {
|
||||
writeSkillDir(
|
||||
projectRoot,
|
||||
"global",
|
||||
@@ -239,8 +239,13 @@ describe("applySkillsIndex", () => {
|
||||
home,
|
||||
);
|
||||
const out = applySkillsIndex(BASE, { homeDir: home, projectRoot, disableBuiltins: true });
|
||||
expect(out).toContain("- 🧬 lookup — Look something up");
|
||||
// Name-first, tag-after: prevents the model from copying "🧬 lookup"
|
||||
// as the skill name into `run_skill({ name: ... })`.
|
||||
expect(out).toContain("- lookup [🧬 subagent] — Look something up");
|
||||
expect(out).toContain("- fmt — Format the codebase");
|
||||
// Old "🧬 name" format must not regress — there was a user bug where
|
||||
// the model copied the marker verbatim and run_skill failed lookup.
|
||||
expect(out).not.toMatch(/- 🧬 lookup\b/);
|
||||
expect(out).not.toContain("- 🧬 fmt");
|
||||
});
|
||||
});
|
||||
@@ -341,10 +346,10 @@ describe("Built-in skills", () => {
|
||||
expect(store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("builtins surface with the 🧬 marker in applySkillsIndex", () => {
|
||||
it("builtins surface with the subagent tag after the name in applySkillsIndex", () => {
|
||||
const out = applySkillsIndex(BASE, { homeDir: home }); // builtins ON
|
||||
expect(out).toContain("# Skills");
|
||||
expect(out).toContain("🧬 explore");
|
||||
expect(out).toContain("🧬 research");
|
||||
expect(out).toContain("explore [🧬 subagent]");
|
||||
expect(out).toContain("research [🧬 subagent]");
|
||||
});
|
||||
});
|
||||
+42
-12
@@ -184,6 +184,29 @@ describe("handleSlash", () => {
|
||||
expect(loop.branchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("/effort <high|max> flips the reasoningEffort cap", () => {
|
||||
const loop = makeLoop();
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
handleSlash("effort", ["high"], loop);
|
||||
expect(loop.reasoningEffort).toBe("high");
|
||||
handleSlash("effort", ["max"], loop);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/effort with no arg reports the current value, doesn't change it", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("effort", [], loop);
|
||||
expect(r.info).toMatch(/reasoning_effort → max/);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/effort rejects unknown values", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("effort", ["low"], loop);
|
||||
expect(r.info).toMatch(/usage: \/effort <high\|max>/);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/branch caps at 8", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("branch", ["99"], loop);
|
||||
@@ -242,7 +265,10 @@ describe("handleSlash", () => {
|
||||
content: "ERROR: line failed with detail and context\n".repeat(2000),
|
||||
});
|
||||
const r = handleSlash("compact", [], loop);
|
||||
expect(r.info).toMatch(/compacted 1 tool result/);
|
||||
// Slash message now covers both passes (tool results + tool-call
|
||||
// args); the "1 payload" and a token-saved count are what matters
|
||||
// for this test's invariant.
|
||||
expect(r.info).toMatch(/compacted 1 payload/);
|
||||
expect(r.info).toMatch(/saved [\d,]+ tokens/);
|
||||
// After compaction the tool content should be much smaller than
|
||||
// the original 84KB, comfortably under the cap's char worst-case.
|
||||
@@ -271,31 +297,35 @@ describe("handleSlash", () => {
|
||||
expect(r.info).toMatch(/\/compact/);
|
||||
});
|
||||
|
||||
it("/preset fast = deepseek-chat, no harvest, no branch", () => {
|
||||
it("/preset fast = v4-flash + effort=high, no harvest, no branch", () => {
|
||||
const loop = makeLoop();
|
||||
handleSlash("model", ["deepseek-reasoner"], loop);
|
||||
handleSlash("model", ["deepseek-v4-pro"], loop);
|
||||
handleSlash("harvest", ["on"], loop);
|
||||
handleSlash("branch", ["3"], loop);
|
||||
handleSlash("preset", ["fast"], loop);
|
||||
expect(loop.model).toBe("deepseek-chat");
|
||||
expect(loop.model).toBe("deepseek-v4-flash");
|
||||
expect(loop.reasoningEffort).toBe("high");
|
||||
expect(loop.harvestEnabled).toBe(false);
|
||||
expect(loop.branchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("/preset smart = reasoner + harvest, no branch", () => {
|
||||
it("/preset smart = v4-flash + effort=max, no harvest, no branch", () => {
|
||||
const loop = makeLoop();
|
||||
handleSlash("preset", ["smart"], loop);
|
||||
expect(loop.model).toBe("deepseek-reasoner");
|
||||
expect(loop.harvestEnabled).toBe(true);
|
||||
expect(loop.model).toBe("deepseek-v4-flash");
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
expect(loop.harvestEnabled).toBe(false);
|
||||
expect(loop.branchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("/preset max = reasoner + harvest + branch3", () => {
|
||||
it("/preset max = v4-pro + effort=max, no harvest, no branch", () => {
|
||||
const loop = makeLoop();
|
||||
handleSlash("preset", ["max"], loop);
|
||||
expect(loop.model).toBe("deepseek-reasoner");
|
||||
expect(loop.harvestEnabled).toBe(true);
|
||||
expect(loop.branchOptions.budget).toBe(3);
|
||||
expect(loop.model).toBe("deepseek-v4-pro");
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
expect(loop.harvestEnabled).toBe(false);
|
||||
// Branch is NEVER auto-enabled by a preset — manual /branch only.
|
||||
expect(loop.branchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("/preset with bad name returns usage", () => {
|
||||
@@ -305,7 +335,7 @@ describe("handleSlash", () => {
|
||||
|
||||
it("/help mentions presets", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/Presets:/);
|
||||
expect(r.info).toMatch(/Presets/);
|
||||
expect(r.info).toMatch(/fast/);
|
||||
expect(r.info).toMatch(/smart/);
|
||||
expect(r.info).toMatch(/max/);
|
||||
|
||||
@@ -288,7 +288,10 @@ describe("registerSubagentTool", () => {
|
||||
registerSubagentTool(parent, { client });
|
||||
// "gpt-4" is not a deepseek-* model — should be ignored.
|
||||
await parent.dispatch("spawn_subagent", JSON.stringify({ task: "go", model: "gpt-4" }));
|
||||
expect(seenModels[0]).toBe("deepseek-chat");
|
||||
// Subagent default was pro pre-0.6; now flash to keep explore/research
|
||||
// cheap. Skill frontmatter `model:` is the opt-in override for skills
|
||||
// that empirically benefit from pro.
|
||||
expect(seenModels[0]).toBe("deepseek-v4-flash");
|
||||
});
|
||||
|
||||
it("aborts the child when the parent's tool ctx signal fires", async () => {
|
||||
|
||||
+24
-4
@@ -97,13 +97,33 @@ describe("inputCostUsd / outputCostUsd", () => {
|
||||
});
|
||||
|
||||
it("chat and reasoner are unified at the same price", () => {
|
||||
// Post-unification (2026-04): DeepSeek charges identically for both
|
||||
// models. If this test starts failing, either DeepSeek reintroduced
|
||||
// tiered pricing (update the constants) or someone accidentally
|
||||
// edited only one entry — catch that before shipping.
|
||||
// 2026-04 V4 launch: `deepseek-chat` and `deepseek-reasoner` are
|
||||
// compat aliases for v4-flash's non-thinking and thinking modes
|
||||
// respectively, so billing is identical. If this diverges, either
|
||||
// DeepSeek split them again (update the constants) or one alias
|
||||
// got out of sync during an update — catch before shipping.
|
||||
const chat = DEEPSEEK_PRICING["deepseek-chat"]!;
|
||||
const reasoner = DEEPSEEK_PRICING["deepseek-reasoner"]!;
|
||||
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
|
||||
expect(reasoner).toEqual(chat);
|
||||
expect(chat).toEqual(flash);
|
||||
});
|
||||
|
||||
it("v4-pro pricing is present and strictly above v4-flash", () => {
|
||||
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
|
||||
const pro = DEEPSEEK_PRICING["deepseek-v4-pro"]!;
|
||||
expect(pro.inputCacheHit).toBeGreaterThan(flash.inputCacheHit);
|
||||
expect(pro.inputCacheMiss).toBeGreaterThan(flash.inputCacheMiss);
|
||||
expect(pro.output).toBeGreaterThan(flash.output);
|
||||
});
|
||||
|
||||
it("v4-pro cost is computed with its own tier, not flash's", () => {
|
||||
// Sanity: passing the pro model to costUsd doesn't silently fall
|
||||
// back to flash rates, otherwise billing on pro would under-count.
|
||||
const u = new Usage(0, 100, 0, 0, 1000);
|
||||
const flashCost = costUsd("deepseek-v4-flash", u);
|
||||
const proCost = costUsd("deepseek-v4-pro", u);
|
||||
expect(proCost).toBeGreaterThan(flashCost * 5); // ~12x on output+miss
|
||||
});
|
||||
|
||||
it("both return 0 for an unknown model", () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Tool-call "ready" progress signal during streaming.
|
||||
*
|
||||
* Matches the 0.5.19 UX fix: as each tool_call's arguments stream into
|
||||
* valid JSON we emit `tool_call_delta` with an incrementing
|
||||
* `toolCallReadyCount`, so the UI can render "N ready · building call
|
||||
* M" instead of a context-free spinner. Dispatch still happens after
|
||||
* the stream ends — this is purely a visibility improvement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCompleteJson } from "../src/loop.js";
|
||||
|
||||
describe("looksLikeCompleteJson", () => {
|
||||
it("empty / whitespace → false", () => {
|
||||
expect(looksLikeCompleteJson("")).toBe(false);
|
||||
expect(looksLikeCompleteJson(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("partial JSON → false (the common streaming case)", () => {
|
||||
expect(looksLikeCompleteJson("{")).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path"')).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hel')).toBe(false);
|
||||
});
|
||||
|
||||
it("complete JSON object → true", () => {
|
||||
expect(looksLikeCompleteJson("{}")).toBe(true);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hello"}')).toBe(true);
|
||||
});
|
||||
|
||||
it("complete JSON array → true", () => {
|
||||
expect(looksLikeCompleteJson("[]")).toBe(true);
|
||||
expect(looksLikeCompleteJson('[{"a": 1}]')).toBe(true);
|
||||
});
|
||||
|
||||
it("primitive JSON values → true", () => {
|
||||
expect(looksLikeCompleteJson("true")).toBe(true);
|
||||
expect(looksLikeCompleteJson("42")).toBe(true);
|
||||
expect(looksLikeCompleteJson('"text"')).toBe(true);
|
||||
});
|
||||
|
||||
it("valid JSON with trailing whitespace → true", () => {
|
||||
expect(looksLikeCompleteJson('{"a":1}\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,32 @@ describe("run_skill tool", () => {
|
||||
expect(JSON.parse(out).error).toMatch(/requires a 'name'/);
|
||||
});
|
||||
|
||||
it("normalizes decorated names (emoji / brackets) to the bare identifier", async () => {
|
||||
// Reproduces the bug where the model copied the `[🧬 subagent]` tag
|
||||
// from the Skills index into the `name` argument verbatim. The
|
||||
// tool strips leading non-word chars + anything past the first
|
||||
// whitespace token, so these all resolve to the same skill.
|
||||
writeSkill(home, "explore", "Look around", "body");
|
||||
const reg = new ToolRegistry();
|
||||
registerSkillTools(reg, { homeDir: home, disableBuiltins: true });
|
||||
|
||||
const cases = [
|
||||
"🧬 explore",
|
||||
"[🧬 subagent] explore",
|
||||
"[🧬] explore",
|
||||
" explore ",
|
||||
"explore [🧬 subagent]",
|
||||
];
|
||||
for (const name of cases) {
|
||||
const out = await reg.dispatch("run_skill", { name });
|
||||
// Inline skills return the body (non-JSON markdown) on success;
|
||||
// an unknown-skill error returns JSON. Presence of the unknown-
|
||||
// skill text in the output is a guaranteed failure marker.
|
||||
expect(out, `case ${JSON.stringify(name)}`).not.toMatch(/unknown skill/i);
|
||||
expect(out, `case ${JSON.stringify(name)}`).toContain("Skill: explore");
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches subagent-runAs skills through subagentRunner", async () => {
|
||||
writeSkillWithFrontmatter(
|
||||
home,
|
||||
|
||||
@@ -188,4 +188,83 @@ describe("ToolRegistry", () => {
|
||||
expect(reg.wasFlattened("deep")).toBe(false);
|
||||
expect(reg.specs()[0]!.function.parameters.properties).toHaveProperty("a");
|
||||
});
|
||||
|
||||
describe("tool interceptor", () => {
|
||||
it("short-circuits dispatch when interceptor returns a string", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
let fnCalled = false;
|
||||
reg.register({
|
||||
name: "edit_file",
|
||||
fn: () => {
|
||||
fnCalled = true;
|
||||
return "should not run";
|
||||
},
|
||||
});
|
||||
reg.setToolInterceptor((name) => (name === "edit_file" ? "queued" : null));
|
||||
const out = await reg.dispatch("edit_file", '{"path":"foo"}');
|
||||
expect(out).toBe("queued");
|
||||
expect(fnCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("falls through to tool.fn when interceptor returns null", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({ name: "read_file", fn: () => "content" });
|
||||
reg.setToolInterceptor(() => null);
|
||||
const out = await reg.dispatch("read_file", "{}");
|
||||
expect(out).toBe("content");
|
||||
});
|
||||
|
||||
it("receives parsed args (including flattened→nested)", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "edit_file",
|
||||
fn: () => "ok",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" }, search: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
});
|
||||
let seen: Record<string, unknown> | null = null;
|
||||
reg.setToolInterceptor((_name, args) => {
|
||||
seen = args;
|
||||
return "captured";
|
||||
});
|
||||
await reg.dispatch("edit_file", '{"path":"a","search":"b"}');
|
||||
expect(seen).toEqual({ path: "a", search: "b" });
|
||||
});
|
||||
|
||||
it("does not fire in plan mode — plan-mode refusal wins", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
let interceptorCalled = false;
|
||||
reg.register({ name: "edit_file", fn: () => "ok" });
|
||||
reg.setToolInterceptor(() => {
|
||||
interceptorCalled = true;
|
||||
return "queued";
|
||||
});
|
||||
reg.setPlanMode(true);
|
||||
const out = await reg.dispatch("edit_file", '{"path":"x"}');
|
||||
expect(JSON.parse(out).error).toMatch(/unavailable in plan mode/);
|
||||
expect(interceptorCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("setToolInterceptor(null) clears a prior install", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({ name: "edit_file", fn: () => "fn-output" });
|
||||
reg.setToolInterceptor(() => "queued");
|
||||
expect(await reg.dispatch("edit_file", "{}")).toBe("queued");
|
||||
reg.setToolInterceptor(null);
|
||||
expect(await reg.dispatch("edit_file", "{}")).toBe("fn-output");
|
||||
});
|
||||
|
||||
it("surfaces interceptor throws as structured errors", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({ name: "edit_file", fn: () => "ok" });
|
||||
reg.setToolInterceptor(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const out = await reg.dispatch("edit_file", "{}");
|
||||
expect(JSON.parse(out).error).toMatch(/interceptor failed — boom/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user