Compare commits

...
11 Commits
Author SHA1 Message Date
esengine c594ad71f7 feat: /permissions slash + MCP Streamable HTTP transport
- /permissions list/add/remove/clear — per-project shell allowlist
  audit + edit from inside the TUI; surfaces edit-mode banner
  (yolo bypasses allowlist), refuses to clobber the read-only
  builtin list, alias /perms.
- removeProjectShellAllowed + clearProjectShellAllowed in config.ts
  for the slash to call (literal-only match, scoped per project).
- StreamableHttpTransport (MCP 2025-03-26): single endpoint, handles
  202 / application/json / text/event-stream responses, captures
  Mcp-Session-Id and echoes on subsequent POSTs, surfaces session
  expiry as a clear error.
- Spec parser: streamable+http(s):// prefix routes to the new
  transport; plain http(s):// stays SSE for back-compat. Wired
  through chat / run / mcp inspect.

1521 tests pass (+24). Lint / typecheck / build clean.
2026-04-27 22:02:18 -07:00
esengine 23c81e6f11 chore(release): 0.11.2
Split /init into its own patch release. /init landed after the
0.11.1 commit, and it's a small but distinct addition (no fix /
no behavioral change to existing flows), so a separate version
makes the changelog story cleaner: 0.11.1 is the workspace-switching
release, 0.11.2 is the project-init release.
2026-04-27 07:23:48 -07:00
esengine 0d0cdfc54a docs: fold /init into 0.11.1 changelog, restore missing 0.11.0 header
Two CHANGELOG fixes ahead of publishing 0.11.1:

1. Add a bullet for /init under 0.11.1 Added. /init was committed
   after the 0.11.1 version bump but before publication, so the
   shipped 0.11.1 actually includes it. Update the headline to
   acknowledge the addition.

2. Restore the '## [0.11.0] — 2026-04-27' header that was clobbered
   when the 0.11.1 entry was inserted earlier — the 0.11.0 content
   was orphaned without a heading.
2026-04-27 07:20:11 -07:00
esengine 60c598527d feat: /init — model-driven REASONIX.md generator
New slash command that asks the model to scan the project and write a
concise REASONIX.md baseline, removing the friction of having to
hand-author one. The file is auto-pinned into the system prompt every
launch, so a good baseline pays back on every subsequent turn.

Mechanism: handler emits a structured user-turn prompt via `resubmit`
that hard-constrains the model to a fact-only document with sections
for Stack / Layout / Commands / Conventions / Watch out for. The
"output ≤ 80 lines / 3KB" cap and "STOP after writing" line are
load-bearing — without the cap REASONIX.md would burn cache budget
every turn; without STOP flash chains follow-up suggestions and
wastes tokens.

Reuses existing filesystem tools (read_file / directory_tree /
edit_file / write_file) — no new tool, no new pipeline. The
generated content lands as a pending edit in the normal review queue,
so the user audits it via /apply before disk writes.

Edge cases:
- Outside code mode: handler returns "/init only works in code mode"
  with the `reasonix code` invocation hint.
- REASONIX.md already exists: refused, points at `/init force` to
  regenerate. Force matching is case-insensitive.

Five tests covering: refuse outside code mode, fresh-init resubmit
shape (verifies length cap + STOP marker survive future tightenings),
existing-file refusal, force override, force case-insensitivity.

1487 tests passing.
2026-04-27 06:55:20 -07:00
esengine 494c62ef60 docs: rebuild Claude-Code-on-DeepSeek section around cache_control
DeepSeek now ships an official Anthropic-compatible endpoint at
api.deepseek.com/anthropic, so the previous 'Claude Code can't run
DeepSeek' framing is factually wrong: Claude Code talks to it
unmodified.

The defense moves from 'protocol incompatible' to 'cache mechanic
incompatible':

- Anthropic native cache is marker-based: cache_control on a message
  caches up-to-this-marker as a content-addressed unit. Claude Code's
  loop is designed around this — markers compensate for an otherwise
  non-stable prefix.
- DeepSeek's auto-cache is byte-stable: it fingerprints the literal
  byte stream from 0. No markers.
- DeepSeek's compat endpoint IGNORES cache_control. Claude Code's
  main caching strategy disappears; auto-cache misses because the
  prefix wasn't byte-stable to begin with.

Reasonix's loop was designed around byte-stable prefix from line one
(append-only invariant). That's why 94.4% on the same workload
where a cache-hostile baseline lands at 46.6%.

Restructure the section into two subheaders:
- 'But DeepSeek now has an Anthropic-compatible API...' — handles
  the Claude Code case head-on with DeepSeek's compat table and the
  marker-vs-byte-stable contrast
- 'What about Aider / Cline / Continue?' — handles the natively-
  DeepSeek tools, keeps the existing 5-row DeepSeek-specific quirks
  table (R1 thinking leak, schema flatten, repair, effort, tail-only
  compaction)

Mirror everything in README.zh-CN.md.
2026-04-27 06:37:15 -07:00
esengine c43ada74e2 docs: handle 'just point Claude Code / Aider at DeepSeek' objection
The 'Why Reasonix?' section explained why we beat generic competitors,
but didn't cover the sharper rebuttal: 'I'll just point Claude Code
(or Aider, Cline, Continue) at DeepSeek and get the same economics
without your tool.' Without an answer prospects bounce silently.

Add a third subsection to 'Why Reasonix?' that handles it head-on:

- Claude Code: not really. It's hard-bound to Anthropic's API —
  auth, tool-schema shape, <thinking> parsing all assume Claude.
  A translation proxy (claude-code-router) gets you protocol
  compatibility but every Anthropic-tuning becomes overhead and
  every DeepSeek-tuning is unreachable.
- Aider / Cline / Continue: yes, they support DeepSeek and you get
  the cheap token price. What you don't get is the DeepSeek-
  specific loop work, captured in a 6-row table contrasting how
  each layer (cache markers, reasoning blocks, tool schemas,
  malformed JSON, reasoning-effort, compaction) differs between
  Anthropic/OpenAI assumptions and DeepSeek reality, with what
  Reasonix does about each.

Then drive the cache-stability claim home with concrete numbers:
generic harnesses land at 40-60% cache hit on DeepSeek vs 94.4% for
Reasonix on the same workload, which at DeepSeek's pricing is ~2.5×
on input cost alone.

Mirror in README.zh-CN.md.
2026-04-27 06:22:26 -07:00
esengine 18b8d0d8df docs: add 'Why Reasonix?' positioning section to README
The README read like a feature manual — "here's everything we have"
without ever telling a new visitor why they'd pick Reasonix over
Cursor / Claude Code / Cline / Aider. Most prospects bounce because
the unique value (DeepSeek's price × cache-first prefix engineering)
isn't visible above the fold.

Insert a "Why Reasonix?" section between Quick start and the code-
mode walkthrough, with three pieces:

1. Three short pitches: cost economics that actually land in your
   bill (~30× cheaper than Claude Sonnet, with 94% live cache hit
   verified in benchmarks/), terminal-native (no Electron / IDE
   plugin), MIT-licensed and hackable end-to-end.
2. A side-by-side comparison table — Reasonix vs the four
   alternatives, on backend / cost / runtime / license /
   cache-hit / MCP / plan mode / skills / edit review / workspace
   switch / cost dashboard / sandbox enforcement.
3. An honest "Pick something else when" section pointing at Aider,
   Cline, Cursor, and Claude Code's IDE mode for the cases where
   Reasonix's tradeoffs don't fit (multi-provider, IDE integration,
   PhD-level reasoning benchmarks, fully-local).

Also tighten the subtitle under # Reasonix to lead with the cost
angle instead of "no LangChain" (audience-correct, but doesn't
help conversion). Mirror everything in README.zh-CN.md. Bump the
test-count claim in Development from 1007 to 1482.
2026-04-27 04:37:38 -07:00
esengine 855b91c7ed feat: workspace switching (/cwd + change_workspace) + 4 fixes
Add two ways to pivot to another project mid-session:
- /cwd <path> — user-typed slash; validates target, applies via
  applyCwdChange (re-registers filesystem/shell/memory tools,
  reloads hooks, syncs loop.hookCwd, re-registers run_skill).
  MCP servers stay on the launch root (no reconnect handshake).
- change_workspace tool — model-callable, gated on a
  WorkspaceConfirm modal. Tool fn always throws
  WorkspaceConfirmationError; App detects the marker, mounts
  Switch/Deny modal; approval drives the same applyCwdChange path.
  No always-allow option — switches are per-target.

Fixes that surfaced from real-use workspace-switching attempts:
- loop: reset _turnAbort after Esc-abort branch returns. Prior
  carry-abort logic re-aborted at iter 0 on every subsequent
  step(), poisoning the session until restart.
- shell: collect Buffer chunks instead of decoding per-chunk;
  smartDecodeOutput tries strict UTF-8, falls back to GB18030 on
  Windows when invalid sequences detected. Fixes mojibake on
  Chinese-Windows cmd.exe error messages (chcp 65001 doesn't
  cover them — they come from a localized resource DLL).
- markdown: gate stripMath pipeline on a math-marker pre-check.
  The catch-all \[a-zA-Z]+ → "" had been wiping \TEST out of
  Windows paths like F:\TEST1, rendering them as F:1.
- prompt: tell the model to call change_workspace (or surface
  /cwd) when the user asks to change projects, instead of
  fumbling with cd /d / pushd / 2>&1 shell hacks.

Bump 0.11.0 → 0.11.1, CHANGELOG entry covers all four fixes plus
the two new entry points. 1482 tests passing.
2026-04-27 04:29:31 -07:00
esengine efcab7d141 chore(release): 0.11.0 2026-04-26 21:02:32 -07:00
esengine ca1406d864 feat: local semantic_search index + Ollama launcher + i18n; fix subagent abort races
Pillar 1 — semantic search (opt-in, local):
- src/index/semantic/{embedding,chunker,store,builder,tool,preflight,
  ollama-launcher,i18n}.ts — Ollama-backed local embedding index for
  the project. Cosine search over Float32 JSONL store, mtime-based
  incremental rebuild, fault-tolerant per-chunk embedding so a single
  oversized file doesn't kill the whole build.
- New CLI: `reasonix index [--rebuild --model --ollama-url --yes]`
  with TTY-aware Braille spinner + elapsed-time + phase labels (no
  more "stuck on building…"). Non-TTY path still emits one line per
  phase for CI logs.
- Preflight checks Ollama binary / daemon / model and prompts (or
  --yes auto) to fix what's missing. All user-facing strings route
  through src/index/semantic/i18n.ts (zh/en, system-locale auto-detect
  via REASONIX_LANG → LANG/LC_ALL → Intl.DateTimeFormat).
- `/semantic` slash inside reasonix code shows status + how to enable.
  No startup auto-prompt — opt-in via the explicit shell command,
  same pattern /setup and /update use.
- semantic_search tool registers only when an index exists.
- code-mode system prompt gets a routing fragment when the tool is
  registered, telling the model to prefer semantic_search for
  descriptive queries and fall back to grep for exact tokens.

Pillar 2 — fix subagent Esc race:
- DOM addEventListener("abort", …) doesn't fire for already-aborted
  signals. If the parent aborted before the listener attached, the
  child ran free until iter budget. Sync-check .aborted at attach.
- loop.ts step() reassigning _turnAbort lost prior aborted state.
  Forward the carry-over so abort() called before step() runs still
  kills it at iter 0.
- subagent treated forcedSummary assistant_final events as success,
  so /skill claimed an aborted run finished normally. Now surfaces
  forcedSummary as an error → success: false.

Tests (+27): chunker (incl. window-split + hard-truncate-overlong-line),
store roundtrip, embedAll null-slot fault tolerance, i18n locale
detection + EN/ZH dicts, /semantic slash status renderer, bootstrap
contract (no startup prompt), prompt routing fragment, and a regression
test for subagent already-aborted signal handling.
2026-04-26 20:56:53 -07:00
esengine 477e710ad6 docs: bilingual landing site + Chinese README
- docs/ landing page for GitHub Pages with i18n auto-switch (en/zh
  via navigator.language → localStorage), terminal-themed dark UI,
  hero animation that mirrors the real TUI rendering primitives
  (◇/◆ role glyphs, yellow tool pills, rounded cyan EditBlockRow
  with - red / + green diff lines, info-row pending/applied status)
- README.zh-CN.md mirroring the English README structure
- Language switcher header on README.md → zh-CN + website
2026-04-26 18:47:25 -07:00
63 changed files with 8903 additions and 103 deletions

No files matched your search

+6
View File
@@ -24,3 +24,9 @@ sessions/
.turbo/
# Local-only Claude Code settings — personal overrides, never committed.
.claude/settings.local.json
# Per-user Reasonix state under .reasonix/ — committable team-level
# files (settings.json, skills/) stay tracked, but the user-private
# bits (semantic index, sessions, opt-out markers) never should.
.reasonix/semantic/
.reasonix/sessions/
.reasonix/semantic-skip
+282
View File
@@ -3,6 +3,288 @@
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.11.3] — 2026-04-27
**Headline:** Two long-deferred items land — `/permissions` makes the
shell allowlist auditable and editable from inside the TUI, and
Streamable HTTP MCP transport (2025-03-26 spec) clears the last debt
from the v0.3 deferred queue.
### Added
- **`/permissions`** — list / add / remove / clear the shell
allowlist without leaving the session. Bare `/permissions` shows
the current edit mode (review / auto / yolo with a yolo-bypasses-
allowlist banner), the per-project entries with 1-based indices,
and the read-only builtin list grouped by leading verb. Subcommands:
`/permissions add <prefix>` (multi-token OK), `/permissions remove
<prefix-or-N>` (literal match or list index), `/permissions clear
confirm`. Refuses to add a prefix that's already in the builtin
list (no redundant project entry) and refuses to remove a builtin
(read-only). Mutating subcommands require code mode. `perms`
registered as alias.
- **`removeProjectShellAllowed` + `clearProjectShellAllowed`**
exported from `src/config.ts`. The remove helper does literal-
prefix match (not prefix-of-prefix), so dropping `git` doesn't
accidentally remove `git push origin main` if both were stored.
- **MCP Streamable HTTP transport (2025-03-26 spec)** —
`src/mcp/streamable-http.ts` implements the new single-endpoint
protocol. POSTs JSON-RPC frames, handles all three response shapes
(202 Accepted for notifications, `application/json` for single
responses, `text/event-stream` for multi-frame streams covering
progress + response). Captures `Mcp-Session-Id` from the first
response that hands one out and echoes it on every subsequent
request; surfaces 404-with-session as a "session expired" error
so callers know to reinitialize. Long-lived GET stream for
unsolicited server-initiated frames is deliberately deferred —
POST-only handles full request/response/notification traffic
for every server we'd realistically point at today.
- **Spec parser** — `streamable+http(s)://` prefix routes to the
new transport (`{ transport: "streamable-http", url, name }`).
Plain `http(s)://` still routes to SSE (2024-11-05) so existing
`--mcp` config entries keep working without surprise upgrades.
Wired through `chat.tsx`, `run.ts`, and `reasonix mcp inspect`.
Public API gains `StreamableHttpTransport` + the
`StreamableHttpMcpSpec` type re-export.
### Tests
- `tests/permissions-slash.test.ts` — 16 tests covering listing,
add, remove (by prefix and by 1-based index), clear, mode banner,
builtin-collision rejection, codeRoot guard, alias.
- `tests/config.test.ts` — 6 new tests for `removeProjectShellAllowed`
/ `clearProjectShellAllowed` (literal-only matching, scoping per
project, idempotent counts).
- `tests/mcp-streamable-http.test.ts` — 8 tests against an in-process
`http.Server` fake that speaks the 2025-03-26 wire shape: JSON
response delivery, 202 ack as no-op, session-id capture+echo,
multi-frame SSE ordering (progress → response), full McpClient
initialize → tools/list round-trip, 404+session = "expired",
500-as-error from `send()`, `close()` unblocks idle iterators.
- `tests/mcp-spec.test.ts` — 4 tests for the new prefix parsing.
1521 tests pass (+24). Lint / typecheck / build clean.
## [0.11.2] — 2026-04-27
**Headline:** `/init` synthesizes a baseline REASONIX.md so a new
project starts with context instead of cold. Closes the gap with
Claude Code's `/init`, scoped to the structure REASONIX expects.
### Added
- **`/init`** — model-driven REASONIX.md generator. The slash
emits a structured user-turn prompt (via the `resubmit` channel)
that hard-constrains the model to a fact-only document with
Stack / Layout / Commands / Conventions / Watch out for sections,
capped at 80 lines / 3KB so REASONIX.md doesn't bloat the system
prompt every launch. Reuses the existing filesystem tools (no new
pipeline) and the result lands as a pending edit in the normal
review queue, so the user audits before it hits disk. Refuses to
overwrite an existing REASONIX.md without `/init force`. Removes
the friction of having to hand-author a project memory file.
## [0.11.1] — 2026-04-27
**Headline:** Workspace-switching, end to end. Four real-use bugs
that all hit the same scenario — `Esc` poisoned the next turn,
Chinese-Windows shell errors came back as mojibake, the markdown
renderer ate `\TEST` out of `F:\TEST1`, and the model had no idea
how to change directories. Plus two new ways to do it: `/cwd <path>`
the user types, and `change_workspace` the model calls (always
gated on an explicit confirmation modal — no auto-switching).
### Fixed
- **`Esc` poisoned the next turn.** The loop's user-Esc abort branch
processed the cancel correctly but left `_turnAbort` in an aborted
state on its way out. The carry-abort logic at `step()` entry then
re-aborted at iter 0 on every subsequent turn, so the user typed
a fresh prompt and saw "stopped without producing a summary"
before any model call ran. The session was effectively dead until
restart. Fix: reset `_turnAbort` to a fresh controller before
returning from the abort branch — the across-turn race that the
carry logic guards against still works because a new `abort()`
fired between turns aborts the new controller. Regression test
added (`tests/loop.test.ts`).
- **Mojibake on Chinese / Japanese / Korean Windows shell errors.**
`runCommand` decoded child output as UTF-8 incrementally per
chunk. Two failure modes:
1. `cmd.exe`'s OWN error messages (e.g. "'sed' is not recognized
as an internal or external command") come from a localized
resource DLL and ignore `chcp 65001`, so on Chinese Windows
the bytes are CP936/GBK and decoded as UTF-8 produced
unreadable garbage.
2. Multi-byte sequences could split across chunk boundaries and
corrupt before the second half arrived.
Fix: collect raw `Buffer[]` chunks and decode once at close via
a new `smartDecodeOutput` — strict UTF-8 first; on Windows fall
back to GB18030 (GBK superset) when UTF-8 rejects the bytes;
last resort lossy UTF-8 keeps the structural exit-code marker
intact. PowerShell's existing `injectPowerShellUtf8` prelude
still covers the PS path; this fixes the path where the model
invokes a native EXE directly (`run_command sed …`).
- **Markdown renderer ate `\TEST` out of `F:\TEST1`.** `stripMath`'s
catch-all LaTeX command stripper (`\\[a-zA-Z]+` → `""`) deleted any
backslash-followed-by-letters sequence — fine for an invented
`\textbf{…}` the model emitted, catastrophic for Windows paths in
prose. `F:\TEST1` rendered as `F:1`. Fix: gate the entire
`stripMath` pipeline on a math-marker pre-check (`$`, `\(`, `\[`,
known LaTeX commands, `^{…}`/`_{…}`, Pandoc super/subscripts). When
none are present we return the string untouched. Mixed inputs (a
path AND real math in the same message) still run the pipeline —
math correctness wins over path preservation in that rare collision.
- **Model didn't know `/cwd` existed.** When asked to switch to a
project on another drive, the model fumbled with `pwd`,
`cd /d F:\TEST1`, and `2>&1` shell tricks (none of which work —
`cd` doesn't carry across `run_command` calls and `2>&1` is rejected
as a shell operator by design). The code-mode system prompt now has
a "When the user wants to switch project / working directory"
section telling the model to surface `/cwd <path>` once and stop,
instead of trying to do it itself.
### Added
- **`change_workspace` tool** — model-callable workspace switching,
gated on a confirmation modal. The tool fn validates the target,
resolves it (absolute / `~`-expanded / relative-to-launch-cwd), then
always throws a `WorkspaceConfirmationError` with the absolute
path. App.tsx detects the marker and mounts a Switch / Deny modal;
on approval it calls the same `applyCwdChange` path that drives
`/cwd` (re-registers filesystem / shell / memory tools, reloads
hooks, syncs the loop's hookCwd). On denial the model gets a
synthetic "user refused, continue without it" message. No
"always allow" option — workspace switches are per-target by
nature. The code-mode system prompt now tells the model to call
this tool (rather than fumble with `cd /d`) when the user asks
to change projects, and to STOP after the call instead of chaining
more tools before the user has confirmed.
- **`/cwd <path>`** — switch the session's working directory mid-
session. Validates the target (must exist, must be a directory),
expands `~`, then atomically: updates the hook cwd, memory root,
project shell allowlist, `@file` mention root, and re-registers
filesystem / shell / memory / `run_skill` tools against the new
path so file reads, edits, and shell commands all land in the
new sandbox. MCP servers stay anchored to the original cwd
(their stdio child was spawned with the launch root and there's
no standard reconnect handshake) — the slash output flags this
explicitly when MCP servers are present. The system prompt's
gitignore-aware project tour is also frozen at launch so the
prefix cache stays valid; the slash output notes it for users
switching to a structurally different project.
## [0.11.0] — 2026-04-27
**Headline:** Local semantic search lands as an opt-in pillar — Ollama-
backed embedding index, `reasonix index` CLI with progress spinner, a
`/semantic` slash for status, and bilingual (zh/en) prompts. Plus a
trio of subagent abort races that made `Esc` silently fail to stop a
running subagent.
### Added — Pillar 5: local semantic search
- **`reasonix index`** — new CLI command that walks the project, line-
windows source files, embeds via Ollama (`nomic-embed-text` by
default, ~274 MB once), and persists a JSONL index at
`.reasonix/semantic/`. Incremental by default (mtime-based), with
`--rebuild` for a full wipe. Per-chunk failures are logged + skipped
so one bad file doesn't kill a 30-minute build.
- **Preflight prompts** — detects missing Ollama binary / daemon /
model and offers to start `ollama serve` or `ollama pull <model>`
with `[Y/n]` confirms. `--yes` for scripts. Non-TTY exits cleanly
with a remediation hint.
- **TTY progress spinner** — Braille `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` ticks every
120ms via `setInterval`, INDEPENDENT of progress events. Builds
that take 30+ seconds never look hung. Non-TTY mode keeps phase
lines + heartbeats for parseable CI logs.
- **Chunker safety** — `maxChunkChars` cap (default 4000 ≈ 1000
tokens) with line-boundary splitting for oversized windows and
hard-truncation for single overlong lines. Fixes Ollama 500 "the
input length exceeds the context length" on minified / dense files.
- **`semantic_search` tool** — registered in `reasonix code` only when
an index exists. Tool description is now directive ("FIRST CHOICE
for descriptive queries"); the code-mode system prompt grows a
`# Search routing` fragment when the tool is registered, telling
the model to prefer semantic_search for intent-style questions
and fall back to grep for exact tokens.
- **`/semantic` slash** — shows status (built? Ollama installed?
daemon up?) plus how-to-enable hints. Fire-and-forget pattern, same
as `/kill` — sync placeholder, async post via `ctx.postInfo`.
- **Bilingual UI** — `src/index/semantic/i18n.ts` with EN/ZH dicts
for every preflight + `/semantic` + progress label. Locale
detection: `REASONIX_LANG` override → `LANG`/`LC_ALL`/`LC_MESSAGES`
(Unix) → `Intl.DateTimeFormat` (Windows fallback) → `en`. Tool
descriptions and CLI `--help` stay English on purpose (model-facing
text aligns with training distribution; commander's --help is
registered once at boot).
- **Startup is silent** — no auto-prompt on `reasonix code` launch.
If an index exists, the tool registers; otherwise the bootstrap
is a no-op. Discovery happens via `/semantic` when the user is
curious, or via the explicit `reasonix index` command.
### Fixed — subagent `Esc` abort races
- **`addEventListener("abort", …)` doesn't replay aborts** — DOM
semantics: an already-aborted signal won't fire the abort event
again, so a parent that aborted before `spawnSubagent` attached
its listener silently lost the cancel. Sync-check `.aborted` at
attach and forward immediately to `childLoop.abort()`.
- **`step()` was overwriting aborted state** — at the top of
`step()` we reassign `_turnAbort = new AbortController()`. If
`loop.abort()` had been called BEFORE `step()` ran, the prior
aborted controller was discarded and the fresh one started clean.
Carry the aborted bit forward so the iter-0 check still bails.
- **`forcedSummary` was treated as success** — when the loop aborted
it yielded a synthetic `assistant_final` with `forcedSummary: true`
and content `"[aborted by user (Esc) — no summary produced.]"`.
The subagent stuffed that into `final` and returned `success: true`,
so `/skill` cheerfully reported "subagent finished" with the abort
message as the answer. Now `forcedSummary` routes to `errorMessage`
→ `success: false` → caller renders the error.
### Added — docs / website
- **GitHub Pages site under `docs/`** — bilingual landing page (auto-
detect via `navigator.language`, manual EN/中文 toggle, persisted
per-browser), brand-gradient dark theme, hero terminal animation
that mirrors the real TUI rendering primitives (◇/◆ role glyphs,
yellow tool pills, rounded cyan EditBlockRow with `- old` red /
`+ new` green diff lines, info-row pending/applied status).
- **`README.zh-CN.md`** — full Chinese mirror of `README.md`. Both
READMEs now carry a language switcher header at the top.
### Tests (+27, 1441 → 1468)
- `tests/semantic-chunker.test.ts` — line-window splitting, overlap,
forward-slash path normalization, NUL-byte sniff; the new
`chunkText` cap behavior (multi-line split + hard-truncate-overlong-
line + idempotent passthrough).
- `tests/semantic-store.test.ts` — JSONL roundtrip, cosine ranking,
minScore threshold, dim-mismatch refusal, model-mismatch refusal,
remove + wipe, fileMtimes.
- `tests/semantic-embed-tolerant.test.ts` — `embedAll` returns
`Array<Float32Array | null>` on per-chunk error (mocked Ollama 500),
abort still throws globally, all-fail surface, progress fires once
per chunk regardless of outcome.
- `tests/semantic-i18n.test.ts` — locale detection precedence,
override env var, placeholder substitution, ZH dict.
- `tests/semantic-bootstrap.test.ts` — registers when index exists,
silent skip otherwise (no startup prompt).
- `tests/semantic-slash.test.ts` — `/semantic` status renderer,
enabled / not-built / Chinese-locale paths.
- `tests/semantic-launcher.test.ts` — `findOllamaBinary` contract.
- `tests/code-prompt.test.ts` — search-routing fragment is absent by
default and present + ordered before .gitignore when the flag is on.
- `tests/subagent.test.ts` — regression: parent signal already aborted
at dispatch time (race we previously dropped on the floor).
### Refactored
- **`src/code/prompt.ts`** — `codeSystemPrompt(rootDir, opts?)` grew
a `hasSemanticSearch` flag; the routing fragment is appended only
when the tool is actually registered. Cache prefix stays stable per
session because the flag is captured at launch.
## [0.6.0] — 2026-04-24
**Headline:** Cost control becomes a first-class pillar. Default flips
+140 -4
View File
@@ -6,6 +6,10 @@
<em>Cache-first agent loop for DeepSeek V4 (flash + pro) — Ink TUI, MCP first-class, no LangChain.</em>
</p>
<p align="center">
<strong>English</strong> · <a href="./README.zh-CN.md">简体中文</a> · <a href="https://esengine.github.io/reasonix/">Website</a>
</p>
# Reasonix
[![npm version](https://img.shields.io/npm/v/reasonix.svg)](https://www.npmjs.com/package/reasonix)
@@ -14,9 +18,10 @@
[![downloads](https://img.shields.io/npm/dm/reasonix.svg)](https://www.npmjs.com/package/reasonix)
[![node](https://img.shields.io/node/v/reasonix.svg)](./package.json)
**A DeepSeek-native AI coding agent in your terminal.** Edits files as
reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No
LangChain.
**A DeepSeek-native AI coding agent in your terminal.** ~30× cheaper
per task than Claude Code, with a cache-first loop engineered for
DeepSeek's pricing model. Edits as reviewable SEARCH/REPLACE blocks.
MIT-licensed. No IDE lock-in. MCP first-class.
---
@@ -67,6 +72,137 @@ command list.
---
## Why Reasonix? (vs Cursor / Claude Code / Cline / Aider)
Three things you'd come to Reasonix for, that nothing else combines:
- **The cost economics actually land in your bill.** DeepSeek V4 is
~30× cheaper than Claude Sonnet per token. Cheaper tokens alone
isn't the win — *cheap tokens with a 90%+ prefix-cache hit* is.
Reasonix's loop is engineered around append-only prompt growth so
the cache-stable prefix survives every tool call, which the
benchmarks section below verifies end-to-end (94.4% live, vs 46.6%
for a generic harness against the same workload). The `/stats`
panel tracks "vs Claude Sonnet 4.6" savings every turn so you can
watch your bill not happen.
- **It lives in your terminal.** Pure CLI — no Electron, no VS Code
extension, no IDE plugin to wedge into your editor. Sits next to
git, tmux, and your shell history. macOS / Linux / Windows
(PowerShell, Git Bash, Windows Terminal all tested). The only
network call is to the DeepSeek API itself; no vendor server in
the middle.
- **Open source and hackable, end to end.** MIT-licensed TypeScript.
The entire loop, tool registry, cache-stable prefix, TUI, MCP
bridge — all in `src/` under 30k lines. Fork it, ship a private
build, drop it into CI. No SaaS layer, no enterprise tier, no
feature gates.
| | Reasonix | Claude Code | Cursor | Cline | Aider |
|---|---|---|---|---|---|
| Backend | DeepSeek V4 only | Anthropic only | OpenAI / Anthropic | any (OpenRouter) | any (OpenRouter) |
| Cost / typical task | **~$0.001–$0.005** | ~$0.05–$0.50 | $20/mo + usage | varies | varies |
| Where it runs | terminal | terminal + IDE | IDE (Electron) | VS Code only | terminal |
| License | **MIT** | closed | closed | Apache 2 | Apache 2 |
| Cache-first prefix loop | **engineered (94% hit)** | basic | n/a | n/a | basic |
| MCP servers | **first-class** | first-class | — | beta | — |
| Plan mode (read-only audit gate) | **yes** | yes | — | yes | — |
| User-authored skills | **yes** | yes | — | — | — |
| Edit review (no auto-write) | **yes** (`/apply`) | yes | partial | yes | yes |
| Workspace switch (`/cwd`, `change_workspace`) | **yes** | — | n/a (per-window) | — | — |
| Cross-session cost dashboard | **yes** (`/stats`) | — | — | — | — |
| Sandbox boundary enforcement | **strict** (refuses `..` escape) | yes | partial | yes | partial |
### Pick something else when
- **You want multi-provider flexibility** (mix Claude / GPT / Gemini /
local Llama in one tool). Try [Aider](https://aider.chat) or
[Cline](https://cline.bot). Reasonix is DeepSeek-only on purpose —
every layer (cache-first loop, R1 harvesting, JSON-mode tool repair,
reasoning-effort cap) is tuned against DeepSeek-specific behavior
and economics. Coupling to one backend is the feature, not a
limitation we'll grow out of.
- **You want IDE integration** (inline diff in your gutter,
multi-cursor, ghost text, refactor previews). Try
[Cursor](https://cursor.com) or Claude Code's IDE mode. Reasonix
is terminal-first; the diff lives in `git diff`, the file tree
lives in `ls`, the chat lives in your shell.
- **You're chasing the hardest reasoning benchmarks.** Claude Opus
4.6 still wins some leaderboards. DeepSeek V4-pro is competitive
on most coding tasks but doesn't lead every benchmark. If your
task is "solve this PhD-level proof" rather than "fix this auth
bug," start with Claude.
- **You need fully-local / fully-free**. DeepSeek's API has free
credit on signup, but isn't free forever. For air-gapped or
always-free, look at Aider + Ollama or [Continue](https://continue.dev).
### "But DeepSeek now has an Anthropic-compatible API — can't I just point Claude Code at it?"
You can. DeepSeek ships an official Anthropic-compatible endpoint at
`https://api.deepseek.com/anthropic`, and Claude Code (or any Anthropic
SDK client) talks to it without modification. The protocol works. The
**caching economics** don't transfer, and that's the whole point.
Look at DeepSeek's [own compatibility table](https://api-docs.deepseek.com/guides/anthropic_api):
| Field | Status on DeepSeek's compat endpoint |
|---|---|
| `cache_control` markers | **Ignored** |
| `mcp_servers` (API-level) | Ignored |
| `thinking.budget_tokens` | Ignored |
| Images / documents / citations | Not supported |
`cache_control: Ignored` is the load-bearing line. Two completely
different cache mechanics are colliding here:
| | Anthropic native | DeepSeek auto-cache |
|---|---|---|
| Model | **Marker-based.** You put `cache_control` on a message; Anthropic caches "everything up to this marker" as a content-addressed unit. Multiple markers = multiple independent breakpoints. | **Byte-stable prefix.** The cache fingerprints the literal byte stream from byte 0. |
| Claude Code's design | Built around this. Markers on system prompt + tool defs let the loop reorder, compact, or insert metadata after the markers without losing the cache. | n/a — Claude Code wasn't designed for byte-stable prefixes. |
| What happens when Claude Code → DeepSeek compat | Markers stripped (ignored). Claude Code's main caching strategy disappears. | Falls back to auto-cache. But Claude Code's prefix isn't byte-stable (markers were the *substitute* for byte-stability), so auto-cache misses too. |
Net effect: **Claude Code's loop, redirected at DeepSeek, gets the
cheap tokens and loses the cache hit it depended on.** A loop running
at 80%+ cache hit on Anthropic's marker cache lands somewhere in the
40-60% range on DeepSeek's auto-cache (matches the generic-harness
baseline in our benchmarks). Same model, same API, same workload —
the loop's invariants don't fit the cache mechanic it's now talking
to.
Reasonix's loop was designed around byte-stable prefix from line one.
No markers, no breakpoints — append-only is the invariant. That's why
the same τ-bench workload lands at **94.4% cache hit** on Reasonix
and **46.6%** on a cache-hostile baseline (committed transcripts;
benchmarks section below). At DeepSeek's pricing — $0.07/Mtok
uncached, ~$0.014/Mtok cached — the difference between 50% and 94%
hit is **roughly 2.5× on input cost alone**.
### "What about Aider / Cline / Continue?"
They support DeepSeek natively (no compat layer needed) and you do
get the cheap token price. What you don't get is the DeepSeek-
specific loop work — those tools' loops support every backend
generically (OpenAI / Anthropic / local Llama / ...) and use
compaction + summarization patterns that destroy byte-stability. They
land in the same 40-60% cache-hit range as the baseline. Plus a
handful of DeepSeek-specific quirks generic loops don't handle:
| Generic loops assume | DeepSeek actually does | Reasonix's fix |
|---|---|---|
| Reasoning emitted as a structured `thinking` block | R1 sometimes leaks tool-call JSON inside `<think>` tags | a `scavenge` pass that pulls escaped tool calls back out, otherwise the model thinks it called and waits for output that never comes |
| Tool schemas validated strictly | DeepSeek silently drops deeply-nested object/array params | auto-flatten — nested params get rewritten to single-level prefixed names so the model sees them at all |
| Tool-call args are well-formed JSON | DeepSeek occasionally produces `string="false"` and other malformed fragments | dedicated `ToolCallRepair` heals the common shapes before they hit dispatch |
| Reasoning depth tuned via system-level switches | V4 exposes a `reasoning_effort` knob (`max` / `high`) | `/effort` slash + `--effort` flag, so users can step down for cheap turns |
| Old tool results kept in full forever | 1M context — don't compact pre-emptively, but most agents do | call-storm breaker + result token cap, but the prefix is *never* rewritten; compaction lands as new turns at the tail |
> Cache-stability isn't a feature you turn on; it's an invariant
> the loop is designed around. Reasonix isn't yet-another agent
> CLI — it's an agent CLI built around DeepSeek's specific cache
> mechanic and pricing model.
---
## `reasonix code` — pair programmer in your terminal
Scoped to the directory you launch from. The model has native
@@ -767,7 +903,7 @@ cd reasonix
npm install
npm run dev code # run CLI from source via tsx
npm run build # tsup to dist/
npm test # vitest (1007 tests)
npm test # vitest (1482 tests)
npm run lint # biome
npm run typecheck # tsc --noEmit
```
+836
View File
@@ -0,0 +1,836 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix — DeepSeek 原生的 agent 框架" width="640"/>
</p>
<p align="center">
<em>为 DeepSeek V4(flash + pro)打造的缓存优先 agent 循环 — Ink TUI、原生 MCP、不依赖 LangChain。</em>
</p>
<p align="center">
<a href="./README.md">English</a> · <strong>简体中文</strong> · <a href="https://esengine.github.io/reasonix/">官方网站</a>
</p>
# Reasonix
[![npm version](https://img.shields.io/npm/v/reasonix.svg)](https://www.npmjs.com/package/reasonix)
[![CI](https://github.com/esengine/reasonix/actions/workflows/ci.yml/badge.svg)](https://github.com/esengine/reasonix/actions/workflows/ci.yml)
[![license](https://img.shields.io/npm/l/reasonix.svg)](./LICENSE)
[![downloads](https://img.shields.io/npm/dm/reasonix.svg)](https://www.npmjs.com/package/reasonix)
[![node](https://img.shields.io/node/v/reasonix.svg)](./package.json)
**DeepSeek 原生的终端 AI 编程代理。** 单次任务成本约为 Claude Code 的
1/30,缓存优先的循环是为 DeepSeek 的定价模型量身打造的。编辑以可审查的
SEARCH/REPLACE 块呈现,落盘前必须确认。MIT 许可、不绑 IDE、原生 MCP。
---
## 60 秒快速上手
**1. 获取 DeepSeek API Key。** 注册即送免费额度:
<https://platform.deepseek.com/api_keys>
**2. 切到项目目录运行。** 无需安装。
```bash
cd my-project
npx reasonix code
```
首次运行会走 30 秒向导(粘贴 API key → 选预设 → 多选 MCP 服务器)。
之后每次直接进入。
**3. 让它改代码。** 模型会以 SEARCH/REPLACE 块的形式提出编辑——
你不 `/apply`,磁盘不会被改。
```
reasonix code › users.ts 里 findByEmail 对大小写敏感导致登录失败,帮我改
assistant
▸ tool<search_files> → src/users.ts, src/users.test.ts
▸ tool<read_file> → (src/users.ts, 412 chars)
▸ 找到了。findByEmail 直接用 === 比对。改成小写规范化并补一条测试。
src/users.ts
<<<<<<< SEARCH
return users.find(u => u.email === email);
=======
const needle = email.toLowerCase();
return users.find(u => u.email.toLowerCase() === needle);
>>>>>>> REPLACE
▸ 1 处待应用编辑(1 个文件)— /apply 写入 · /discard 丢弃
reasonix code › /apply
▸ ✓ applied src/users.ts
```
要求 Node ≥ 20.10。支持 macOS、Linux、Windows(PowerShell · Git Bash ·
Windows Terminal)。任何时候按 `Esc` 中断;`/help` 查看完整命令列表。
---
## 为什么选 Reasonix?(vs Cursor / Claude Code / Cline / Aider)
三件事,别家不会同时都给你:
- **成本节省落到账单上。** DeepSeek V4 的 token 单价大约是 Claude Sonnet
的 1/30。光便宜还不够 —— *便宜的 token 配上 90%+ 的前缀缓存命中*才是关键。
Reasonix 的循环按 append-only 增长设计,缓存稳定的前缀在每次工具调用之间
都活着,下面的 benchmark 章节端到端验证过:实测 94.4% 缓存命中,对照组通用
框架只有 46.6%。`/stats` 面板每轮都跟踪 "vs Claude Sonnet 4.6" 的节省额,
你可以亲眼看着账单不涨。
- **它住在终端里。** 纯 CLI —— 没有 Electron,没有 VS Code 插件,没有要
塞进编辑器的 IDE 插件。和 git、tmux、shell 历史并排。macOS / Linux /
Windows(PowerShell、Git Bash、Windows Terminal 都测过)。唯一的网络
请求就是 DeepSeek API 本身,中间没有厂商服务器。
- **开源且彻底可改。** MIT 许可的 TypeScript。整个循环、工具注册表、
缓存稳定前缀、TUI、MCP 桥接 —— 全部在 `src/` 下,不到 3 万行。Fork
它、做私有构建、塞进 CI 都可以。没有 SaaS 层,没有企业版,没有功能闸门。
| | Reasonix | Claude Code | Cursor | Cline | Aider |
|---|---|---|---|---|---|
| 后端 | 仅 DeepSeek V4 | 仅 Anthropic | OpenAI / Anthropic | 任意(OpenRouter)| 任意(OpenRouter)|
| 单次任务成本 | **~$0.001–$0.005** | ~$0.05–$0.50 | $20/月 + 用量 | 视情况 | 视情况 |
| 运行环境 | 终端 | 终端 + IDE | IDE(Electron)| 仅 VS Code | 终端 |
| 开源协议 | **MIT** | 闭源 | 闭源 | Apache 2 | Apache 2 |
| 缓存优先前缀循环 | **工程化(94% 命中)** | 基础 | n/a | n/a | 基础 |
| MCP 服务器 | **原生支持** | 原生支持 | — | 测试中 | — |
| 计划模式(只读审计闸门)| **支持** | 支持 | — | 支持 | — |
| 用户编写的 skills | **支持** | 支持 | — | — | — |
| 编辑审阅(不自动落盘)| **支持**(`/apply`)| 支持 | 部分 | 支持 | 支持 |
| 工作区切换(`/cwd`、`change_workspace`)| **支持** | — | n/a(每窗一项目)| — | — |
| 跨会话成本面板 | **支持**(`/stats`)| — | — | — | — |
| 沙箱边界强制 | **严格**(拒绝 `..` 逃逸)| 支持 | 部分 | 支持 | 部分 |
### 这些情况下应该选别的
- **你想要多模型混用**(在一个工具里同时切 Claude / GPT / Gemini / 本地 Llama)。
试试 [Aider](https://aider.chat) 或 [Cline](https://cline.bot)。Reasonix
故意只绑 DeepSeek —— 每一层(缓存优先循环、R1 harvest、JSON 模式的工具
调用修复、reasoning_effort 上限)都是为 DeepSeek 的具体行为和经济模型
调出来的。绑死后端是设计选择,不是早晚要解决的限制。
- **你想要 IDE 集成**(编辑器侧边栏 inline diff、多光标、ghost text、重构
预览)。试试 [Cursor](https://cursor.com) 或 Claude Code 的 IDE 模式。
Reasonix 是终端优先的:diff 在 `git diff` 里、文件树在 `ls` 里、对话
在 shell 里。
- **你在追最难的推理 benchmark**。Claude Opus 4.6 还是赢一些榜单的。
DeepSeek V4-pro 在大多数编程任务上都很有竞争力,但不是每个 benchmark
都领先。如果你的任务是"证明这个 PhD 级别的数学命题"而不是"修这个
auth bug",从 Claude 起步更合适。
- **你需要完全本地 / 永远免费**。DeepSeek API 注册送额度,但不是永久
免费。要真正离线/永久免费,看看 Aider + Ollama 或者
[Continue](https://continue.dev)。
### "DeepSeek 现在有 Anthropic 兼容 API 了,我直接拿 Claude Code 接上不就行?"
可以接。DeepSeek 官方提供了 Anthropic 兼容端点
`https://api.deepseek.com/anthropic`,Claude Code(或任何 Anthropic SDK
客户端)不改一行代码就能连上去。**协议跑得通,缓存经济学跑不通** ——
而后者才是关键。
看 [DeepSeek 自己的兼容性表](https://api-docs.deepseek.com/guides/anthropic_api):
| 字段 | 在 DeepSeek 兼容端点上的状态 |
|---|---|
| `cache_control` 标记 | **Ignored(被忽略)** |
| `mcp_servers`(API 层)| Ignored |
| `thinking.budget_tokens` | Ignored |
| 图像 / 文档 / 引用 | 不支持 |
`cache_control: Ignored` 就是杀手级的那一行。这里有**两套完全不同的缓存
机制在打架**:
| | Anthropic 原生 | DeepSeek 自动缓存 |
|---|---|---|
| 模型 | **Marker 驱动。** 你在某条消息上打 `cache_control`,Anthropic 把"到此 marker 为止"的内容做内容寻址缓存。多个 marker = 多个独立断点。 | **Byte-stable prefix。** 缓存对字面字节流从第 0 字节起做指纹。 |
| Claude Code 的设计 | 围绕这个设计的。在 system prompt + tool 定义上插 marker,让 loop 在 marker 之后做重排、压缩、插元数据都不丢缓存。 | n/a —— Claude Code 不是为 byte-stable prefix 设计的。 |
| Claude Code 接 DeepSeek 兼容端点之后 | Marker 被 strip(忽略)。Claude Code 的主缓存策略消失。 | Fallback 到 auto-cache。但 Claude Code 的 prefix 不是 byte-stable 的(marker 本来就是 byte-stability 的*替代*),auto-cache 也命中不了。 |
净效果:**Claude Code 的 loop 重定向到 DeepSeek 之后,便宜 token 拿到了,
原本依赖的缓存命中没了**。一个在 Anthropic marker cache 上 80%+ 命中的
loop,到 DeepSeek 的 auto-cache 上大概率掉到 40-60%(跟我们 benchmark 里
通用 harness 的 baseline 同区间)。同一个模型、同一个 API、同一个负载 ——
loop 的 invariant 跟它现在对话的缓存机制不匹配。
Reasonix 的 loop 从第一行起就是按 byte-stable prefix 的不变量设计的。没有
marker、没有断点 —— append-only 就是 invariant。这就是为什么同一份 τ-bench
负载在 Reasonix 上是 **94.4% 缓存命中**、在 cache-hostile baseline 上是
**46.6%**(已 commit 的 transcript,见下面 benchmark 段)。按 DeepSeek 的
单价 —— $0.07/Mtok 非缓存、约 $0.014/Mtok 缓存命中 —— 50% 和 94% 命中之间
**仅 input 这一侧就大约是 2.5× 的差距**。
### "那 Aider / Cline / Continue 呢?"
它们原生支持 DeepSeek(不需要兼容层),便宜 token 单价你确实拿到了。
但你拿不到 DeepSeek-specific 的循环工程 —— 这些工具的 loop 是为**通用支持**
所有后端(OpenAI / Anthropic / 本地 Llama / ...)设计的,用的是那种会破坏
byte-stability 的通用压缩 / 摘要模式。命中率落在和 baseline 同样的 40-60%
区间。再加上一堆 DeepSeek 怪癖通用 loop 不处理:
| 通用 loop 假定 | DeepSeek 实际行为 | Reasonix 怎么处理 |
|---|---|---|
| 推理通过结构化 `thinking` 块产出 | R1 偶尔把 tool-call JSON 漏到 `<think>` 标签里 | `scavenge` 扫描把漏出的 tool call 拣回,否则模型以为自己已经调用,等不到结果 |
| tool schema 严格校验 | DeepSeek 静默丢弃深嵌套 object/array 参数 | auto-flatten:嵌套参数被重写成单层 prefixed name,模型才看得见 |
| tool-call args 是良构 JSON | DeepSeek 偶发 `string="false"` 之类的破碎片段 | 专用 `ToolCallRepair` 在 dispatch 之前修好常见形状 |
| 推理深度靠系统级开关 | V4 直接暴露 `reasoning_effort` 旋钮(`max` / `high`) | `/effort` slash + `--effort` flag,简单任务可以降到 high 省钱 |
| 老 tool result 永久保留 | 1M context 不需要主动 compact,但通用工具都会做 | call-storm breaker + 结果 token cap,但前缀**永不重写** —— 压缩作为新 turn 追加在尾部 |
> 缓存稳定性不是一个开关,是 loop 设计之初就要建立的不变量。Reasonix
> 不是"又一个 agent CLI",是**围绕 DeepSeek 具体的缓存机制和定价模型
> 设计的 agent CLI**。
---
## `reasonix code` — 终端里的结对编程
作用域为启动目录。模型自带 `read_file` / `write_file` / `edit_file` /
`list_directory` / `search_files` / `directory_tree` / `get_file_info` /
`create_directory` / `move_file`,全部在沙箱内 —— 任何解析后落到启动根目录之外
的路径(包括 `..` 或符号链接逃逸)都会被拒绝。再加上带只读白名单的
`run_command`;任何会修改状态的命令(`npm install`、`git commit` 等)都要走
确认弹窗。
### 流程演示:先看再改
对于"这段代码做什么用?"这类问题,模型会用读取类工具,然后用散文回答 ——
不会出 SEARCH/REPLACE 块、也不会写文件。只有你明确要求修改时它才动手:
```
reasonix code › 这个项目的路由是怎么组织的?
assistant
▸ tool<directory_tree> → (src/ tree, 47 entries)
▸ tool<read_file> → (src/router.ts, 1.2 KB)
▸ 路由分三层:顶层 AppRouter 注册 tab,每个 tab 用 React Router 的
nested routes 写子路径,最后 …
```
`edit_file` 的 SEARCH 块如果没有按字节精确匹配文件内容,编辑会被显式拒绝
而不是模糊匹配。模型能看到错误并自行重试 —— 比起"静默改错","显式拒绝"
是更安全的失败方式。
### Plan 模式 —— 执行前先审阅
任何比"改个 typo"更大的改动,模型会被引导先提交一份 markdown 计划。
你会看到 **批准 / 重做 / 取消** 三选一:
```
reasonix code › 把 auth 从 JWT 迁移到 session cookies
▸ plan submitted — awaiting your review
────────────────────────────────────────
## Summary
Swap JWT middleware for session cookies, keep user table intact.
## Files
- src/auth/middleware.ts — replace `verifyJwt` with `readSession`
- src/auth/session.ts — new file, in-memory store + signed cookie
- src/routes/login.ts — return Set-Cookie instead of a token
- tests/auth/*.test.ts — update fixtures
## Risks
- Existing logged-in users get logged out (no migration).
- Session store is in-memory; restart clears sessions.
────────────────────────────────────────
▸ Approve and implement
Refine — explore more
Cancel
```
**强制启用** 用 `/plan` —— 进入显式只读阶段:模型必须先提交计划,否则任何
编辑或非白名单 shell 调用都不会执行。适合那些你想先审一遍再让模型动手的
高风险改动。`/plan off` 或在弹窗里选 Approve/Cancel 退出。
### 输入前缀 —— `!cmd` 与 `@path`
两个不需要斜杠的内联快捷方式:
**`!<cmd>` —— 在沙箱里跑 shell 命令并把结果喂给模型。** 像 bash 一样在
prompt 里直接打。输出既会进入可见日志,也会进入会话 —— 模型下一轮会基于它
推理:
```
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
▸ …
```
无白名单门 —— 用户主动输入的 shell 命令就是显式同意。60 秒超时、32k 字符
上限,0.5.14 起会话恢复后依然保留。
**`@path/to/file` —— 内联一个文件作为 "Referenced files"。** 输入 `@`
弹出选择器(↑/↓ 切换、Tab/Enter 插入)。比让模型 `read_file` 后再问更省事:
"@src/users.ts 这个文件做什么用?"。沙箱限定:仅相对路径、不允许 `..`、单
文件 64KB 上限。最近用过的文件排在前面。
### `/commit` —— 一步暂存 + 提交
```
reasonix code › /commit "fix: findByEmail case-insensitive"
▸ git add -A && git commit -m "fix: findByEmail case-insensitive"
[main a1b2c3d] fix: findByEmail case-insensitive
```
### 一些值得试试的命令
- `/tool 1` —— 打印最后一次工具调用的完整输出(当 400 字内联剪辑不够看时)。
- `/think` —— 看上一轮模型的完整推理(思考模式:v4-flash / v4-pro / reasoner 别名)。
- `/undo` —— 回滚上一批已应用的编辑。
- `/new` —— 在同一目录开新会话,但不丢失旧会话文件。
- `/effort high` —— 从默认 `max` agent 推理强度降一档,简单任务更省更快。
- `npx reasonix code --preset max` —— v4-pro + 三路 self-consistency 分支,
适合疑难重构。
- `npx reasonix code src/` —— 更窄的沙箱(只有 `src/` 可写)。
- `npx reasonix code --no-session` —— 临时会话,什么都不存。
### `reasonix stats` —— 你到底省了多少?
每次 `reasonix chat|code|run` 跑完都会往
`~/.reasonix/usage.jsonl` 追加一条精简记录(token + 成本 + Claude Sonnet
4.6 同负载下的等价价格)。`reasonix stats` 不带参数会把日志聚合成今日 /
本周 / 本月 / 历史 四个窗口:
```
Reasonix usage — /Users/you/.reasonix/usage.jsonl
turns cache hit cost (USD) vs Claude saved
----------------------------------------------------------------------
today 8 95.1% $0.004821 $0.1348 96.4%
week 34 93.8% $0.023104 $0.6081 96.2%
month 127 94.2% $0.081530 $2.1452 96.2%
all-time 342 94.0% $0.210881 $5.8934 96.4%
```
隐私:日志里只有 token、成本和你自己取的 session 名。没有 prompt、没有
completion、没有工具参数。`reasonix stats <transcript>` 仍兼容老用法
(按文件出 assistant turn + tool call 摘要)。
### 保持最新
面板顶栏在 `Reasonix` 旁边显示当前版本(如
`Reasonix v0.5.21 · deepseek-v4-pro · harvest · max …`,最后那个 `max` 是
推理强度徽章 —— `/effort high` 可降一档)。后台每 24 小时静默查询一次 npm
registry,发现新版会在同一行右侧显示黄色 `update: X.Y.Z`。不阻塞、不烦人,
每天最多查一次,离线 / 防火墙下静默失败。
```bash
reasonix update # 打印当前 vs 最新,并执行 `npm i -g reasonix@latest`
reasonix update --dry-run # 只打印计划,不实际安装
```
通过 `npx` 用?命令会识别这种情况并改为打印 cache 刷新提示 —— npx 在下次
调用时会自动取最新版本。
### 项目约定 —— `REASONIX.md`
把 `REASONIX.md` 放到项目根目录,每次启动都会被钉进 system prompt。可提交
的团队记忆 —— 房间约定、领域词表、模型老忘的事情:
```bash
cat > REASONIX.md <<'EOF'
# Notes for Reasonix
- Use snake_case for new Python modules; legacy camelCase modules keep their style.
- `cargo check` is in the auto-run allowlist; full `cargo test` needs confirmation.
- The `api/` dir mirrors `backend/` — keep schemas in sync.
EOF
```
重启(或 `/new`)后生效;前缀每会话只哈希一次,让 DeepSeek 的缓存保持热。
`/memory` 打印当前已钉的内容。`REASONIX_MEMORY=off` 在 CI / 离线复现时关闭
所有记忆来源。
### 用户记忆 —— `~/.reasonix/memory/`
第二层 **私人按用户** 的记忆放在你的 home 目录。不像 `REASONIX.md` 会被
提交,模型自己也能通过 `remember` 工具往里写。两个作用域:
- `~/.reasonix/memory/global/` —— 跨项目(你的偏好、工具链)。
- `~/.reasonix/memory/<project-hash>/` —— 限定一个 `reasonix code` 沙箱
根目录(决策、本地事实、按仓库的快捷方式)。
每个作用域都维护一个总是加载的 `MEMORY.md` 索引(一行一条)+ 零或多个
`<name>.md` 详情文件(按需通过 `recall_memory` 加载)。写入立刻生效;钉进
system prompt 在下一次 `/new` 或重启时生效,以保证当前会话的缓存前缀稳定。
```
reasonix code › 我用 bun 而不是 npm,请以后都用 bun 跑构建
assistant
▸ tool<remember> → project/bun_build saved
"Build command on this machine is `bun run build`"
```
**斜杠**:`/memory` · `/memory list` · `/memory show <name>` ·
`/memory forget <name>` · `/memory clear <scope> confirm`。
**模型工具**:`remember(type, scope, name, description, content)` ·
`forget(scope, name)` · `recall_memory(scope, name)`。
项目作用域只在 `reasonix code` 里可用(需要真实的沙箱根来计算哈希);纯
`reasonix` 只有全局作用域。
### Skills —— 用户自定义的 prompt 包
Skill 就是你写在磁盘上的散文指令块。Reasonix 把它们的名字 + 一行描述钉
进 system prompt;模型可以在合适时机自动调 `run_skill({name: "..."})`,
你也可以打 `/skill <name> [args]` 手动触发。
两个作用域,与用户记忆同样的布局:
- `<project>/.reasonix/skills/` —— 按项目(提交进 git 给团队,或加入
`.gitignore` 做个人草稿)。
- `~/.reasonix/skills/` —— 全局,到处可用。
两种文件结构都行:`<name>/SKILL.md`(推荐 —— 可同时打包附带资源)或扁平
`<name>.md`。
```markdown
---
name: review
description: Review uncommitted changes and flag risks
---
Run `git diff` on staged and unstaged changes. Summarize what each
hunk does, call out potential regressions, and list files that might
need additional tests. Don't propose edits unless I ask.
```
用法:
```
reasonix code › /skill review
▸ running skill: review
assistant
▸ tool<run_command> → git diff --cached
▸ 3 改动,1 个需要回归测试 …
```
或者让模型自己挑 —— 因为 skill 名字 + 描述都在前缀里,问 "帮我看下未提交
的改动有没有风险" 就会触发 `run_skill({name: "review"})`,不用你打斜杠。
**斜杠**:`/skill`(列出) · `/skill show <name>` · `/skill <name>
[args]`(把 body 作为用户轮注入)。
**故意不绑死** 任何其他客户端的目录约定(`.claude/skills` 等等)——
Reasonix 在对话层是模型无关的。任何 SKILL.md 都能用;body 是散文,所以
为别的工具写的 skill 多半能直接用(Reasonix 工具名不同 —— `filesystem`
/ `shell` / `web` —— 但模型读到指令后会挑我们的等价工具)。
### Hooks —— 围绕工具调用与轮次自动化
在 `.reasonix/`(项目或 `~/`)放一个 `settings.json`,Reasonix 会在
循环里四个常见的点触发 shell 命令:工具运行前、工具返回后、prompt 到达
模型前、轮次结束后。
```json
// <project>/.reasonix/settings.json ← 可提交
// ~/.reasonix/settings.json ← 按用户
{
"hooks": {
"PreToolUse": [{ "match": "edit_file|write_file", "command": "bun scripts/guard.ts" }],
"PostToolUse": [{ "match": "edit_file", "command": "biome format --write" }],
"UserPromptSubmit": [{ "command": "echo $(date +%s) >> ~/.reasonix/prompts.log" }],
"Stop": [{ "command": "bun test --run", "timeout": 60000 }]
}
}
```
每个 hook 都是 shell 命令。Reasonix 调用时通过 stdin 传一份 JSON 信封,
描述事件:
```json
{ "event": "PreToolUse", "cwd": "/path/to/project",
"toolName": "edit_file", "toolArgs": { "path": "src/x.ts", "..." } }
```
退出码决定走向:
- **0** —— 通过;循环正常继续
- **2** —— 阻断(仅对 `PreToolUse` / `UserPromptSubmit` 生效);hook 的
stderr 会作为合成工具结果让模型看到,或者整条 prompt 直接被丢弃
- **其他** —— 警告;循环继续,stderr 渲染为黄色行内提示
`match` 是对工具名的锚定正则;`*` 或省略匹配所有工具。项目 hook 先于全局
hook 触发。默认超时:阻断类事件 5 秒,日志类事件 30 秒;可在每条 hook 里
用 `timeout` 覆盖。
**斜杠**:`/hooks`(列出当前生效的 hook)· `/hooks reload`(不丢会话从磁盘
重读 `settings.json`)。
### 在 TUI 里保持最新
`/update` 在运行中的会话里会显示当前版本、最近一次后台 24h 检查取到的最新
版本,以及实际安装命令。这条斜杠 **不会** 直接 spawn `npm install` ——
stdio:inherit 进入正在跑的 Ink 渲染器会把显示搞乱。要真正升级请退出会话,
在新 shell 里执行 `reasonix update`。
---
## `reasonix` —— 也能当通用聊天
同一套 TUI,没有文件系统工具(除非你通过 MCP 主动接入)。适合起草、问答、
schema 设计、架构讨论,或者驱动你自己的 MCP 服务器。会话按名字保存在
`~/.reasonix/sessions/`。
```bash
npx reasonix # 用已保存配置 + 向导选过的 MCP
npx reasonix --preset smart # 这次跑用 reasoner + R1 harvest
npx reasonix --session design # 命名会话 — 之后 --session design 续聊
```
临时挂 MCP 服务器:
```bash
npx reasonix \
--mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe" \
--mcp "kb=https://mcp.example.com/sse"
```
MCP 工具走和原生工具一样的 Cache-First + 修复 + 上下文安全管线 —— 32k
结果上限、实时进度通知渲染、自动重试。
---
## 会话内命令
**核心**
| 命令 | 作用 |
|---|---|
| `/help` · `/?` | 完整命令参考(带提示) |
| `/status` | 当前模型 · flag · 上下文 · 会话 |
| `/new` · `/reset` | 同会话内开新对话 |
| `/clear` | 仅清空可见 scrollback(日志保留) |
| `/retry` | 截断并重发上一条消息(重新采样) |
| `/exit` · `/quit` | 退出 |
**模型**
| 命令 | 作用 |
|---|---|
| `/preset <fast\|smart\|max>` | 一键预设(model + harvest + branch) |
| `/model <id>` | 切换 DeepSeek 模型(`deepseek-v4-flash`、`deepseek-v4-pro`,加上 `deepseek-chat` / `deepseek-reasoner` 兼容别名) |
| `/models` | 列出 DeepSeek `/models` 端点的可用模型 |
| `/harvest [on\|off]` | 切换 R1 plan-state 提取 |
| `/branch <N\|off>` | 每轮跑 N 路并行采样,挑最佳(N ≥ 2) |
| `/effort <high\|max>` | reasoning_effort 上限 —— `max` 是 agent 默认,`high` 更便宜更快 |
| `/think` | 打印上一轮的完整 thinking-mode 推理 |
**上下文与工具**
| 命令 | 作用 |
|---|---|
| `/mcp` | 列出已挂载的 MCP 服务器及其 tool / resource / prompt |
| `/resource [uri]` | 浏览 + 读取 MCP resource(无参 → 列 URI;`<uri>` → 拉取) |
| `/prompt [name]` | 浏览 + 拉取 MCP prompt |
| `/tool [N]` | 打印第 N 次工具调用的完整输出(1 = 最近) |
| `/compact [tokens]` | 压缩日志里超大的工具结果(默认每条结果 4000 tokens) |
| `/context` | 看上下文 token 都花在哪(system / tools / log) |
| `/stats` | 跨会话成本仪表盘(today / week / month / all-time) |
| `/keys` | 键盘快捷键 + 输入前缀(`!` / `@` / `/`)速查 |
**记忆与 Skill**
| 命令 | 作用 |
|---|---|
| `/memory` | 显示已钉记忆(REASONIX.md + ~/.reasonix/memory) |
| `/memory list` · `show <name>` · `forget <name>` · `clear <scope> confirm` | 管理记忆库 |
| `/skill` · `/skill list` | 列出已发现的 skill(项目 + 全局) |
| `/skill show <name>` | 打印某个 skill 的 body |
| `/skill <name> [args]` | 运行 skill(把 body 作为用户轮注入) |
**会话**
| 命令 | 作用 |
|---|---|
| `/sessions` | 列出已保存的会话(当前用 `▸` 标记) |
| `/forget` | 从磁盘删除当前会话 |
| `/setup` | 重新配置(退出后跑 `reasonix setup`) |
**只在 code 模式下** (`reasonix code`)
| 命令 | 作用 |
|---|---|
| `/apply` | 把待应用的 SEARCH/REPLACE 块写入磁盘 |
| `/discard` | 丢弃待应用的编辑块 |
| `/undo` | 回滚最后一批已应用的编辑 |
| `/commit "msg"` | `git add -A && git commit -m "msg"` |
| `/plan [on\|off]` | 切换只读 plan 模式 |
| `/apply-plan` | 强制批准当前待审计划 |
**键盘**
- `Enter` —— 提交
- `Shift+Enter` / `Ctrl+J` —— 换行(多行粘贴也行;
`\` + Enter 是跨平台兜底)
- `↑` / `↓` —— 空闲时翻 prompt 历史;在斜杠自动补全里上下选
- `/foo` 前缀下按 `Tab` / `Enter` —— 接受高亮的建议
- `Esc` —— 中断当前轮(停 API 调用、取消进行中的工具、拒绝待响应的 MCP 请求)
- 确认弹窗里的 `y` / `n` —— 快捷接受 / 拒绝
---
## 会话与安全网
- 会话以 JSONL 形式保存在 `~/.reasonix/sessions/<name>.jsonl`(`reasonix
code` 按目录分)。每条消息都原子追加;`Ctrl+C` 不会丢上下文。
- 工具结果每次调用最多 32k 字符。超大会话加载时自动自愈(缩小并重写文件)。
- 每次出站 API 调用前会校验 `assistant.tool_calls` / `tool` 的配对,损坏的
会话不会一直 400。
- 上下文仪表 50% 变黄、80% 变红并提示 `/compact`。接近 1M token 上限
(V4 flash + pro)会先尝试自动压缩,再退化为强制总结。
- `reasonix code` 沙箱拒绝任何解析后落到启动目录之外的路径(含符号链接
逃逸和 `..` 穿越)。
### 故障排查:重复行 / 鬼影渲染
有些 Windows 终端(Git Bash / MINTTY / winpty 包装的 shell)没完整实现
Ink 用的 ANSI cursor-up 转义。表现:spinner、流式预览、工具结果行不在原地
覆盖刷新,反而在 scrollback 里印出多份。
遇到了就用 plain 模式:
```bash
REASONIX_UI=plain npx reasonix code
```
Plain 模式抑制了实时 / 动画行,关掉了内部 tick 计时器。代价是失去流式预览
和 spinner,但 scrollback 稳定。Windows Terminal、Windows Terminal 里的
PowerShell 7、WezTerm 不需要这个开关。
---
## Web 搜索 —— 默认开
模型一启动就有两个 web 工具:`web_search` 和 `web_fetch`。无 flag、无 API
key、无注册。当你问到模型训练时没见过的事(新发布、最近事件、冷门 API),
它会自己决定调 `web_search`;snippet 不够就跟一个 `web_fetch`。
底层是 **Mojeek** 的公开搜索页 —— 独立 web 索引,bot 友好,无 cookie /
session。冷门或非常新的查询覆盖率比 Google/Bing 薄一些,但脚本里很可靠。
(DDG 是原始后端,但 2026 年开始上反机器人页。)
**关掉**(离线 / 隐私 / CI):
```json
// ~/.reasonix/config.json
{ "apiKey": "sk-…", "search": false }
```
```bash
REASONIX_SEARCH=off npx reasonix code
```
**接自己的**(Kagi、SearXNG、内部缓存):实现 `WebSearchProvider` 接口
然后自己调 `registerWebTools(registry, { provider })`,或通过 `--mcp`
桥接已有的 MCP 搜索服务器。
---
## MCP —— 自带工具
任意 [MCP](https://spec.modelcontextprotocol.io/) 服务器都能用。向导内置
目录可选,或用 flag 直接挂:
```bash
# stdio(本地子进程)
npx reasonix --mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe"
# 同时挂多个
npx reasonix \
--mcp "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe" \
--mcp "demo=npx tsx examples/mcp-server-demo.ts"
# HTTP+SSE(远程 / 托管)
npx reasonix --mcp "kb=https://mcp.example.com/sse"
```
`reasonix mcp list` 显示精选目录。`reasonix mcp inspect <spec>` 不开聊天,
直接连一次并打印服务器的 tool / resource / prompt。长任务的进度通知
(2025-03-26 spec)会以进度条形式实时渲染在 spinner 里。
支持的传输:**stdio**(本地命令)和 **HTTP+SSE**(远程,MCP 2024-11-05
spec)。
---
## CLI 参考
```bash
npx reasonix code [path] # 编程模式,作用域 path(默认 cwd)
npx reasonix # 聊天(用已保存配置)
npx reasonix setup # 重跑配置向导
npx reasonix chat --session work # 命名会话
npx reasonix chat --no-session # 临时会话
npx reasonix run "ask anything" # 一次性运行,结果流到 stdout
npx reasonix stats session.jsonl # 总结一份 transcript
npx reasonix replay chat.jsonl # 从 transcript 重建成本/缓存视图
npx reasonix diff a.jsonl b.jsonl --md # 对比两份 transcript
npx reasonix mcp list # 精选 MCP 目录
npx reasonix mcp inspect <spec> # 探测单个 MCP 服务器
npx reasonix sessions # 列已保存会话
```
常用 flag:
```bash
--preset <fast|smart|max> # 预设组合(model + harvest + branch)
--model <id> # 显式指定模型 ID
--harvest / --no-harvest # R1 plan-state 提取
--branch <N> # self-consistency 预算
--mcp "name=cmd args…" # 挂 MCP 服务器(可重复)
--transcript path.jsonl # 同时旁路写一份 JSONL transcript
--session <name> # 命名会话(code 模式默认按目录)
--no-session # 临时会话
--no-config # 忽略 ~/.reasonix/config.json(CI 友好)
```
环境变量(覆盖 config):
```bash
export DEEPSEEK_API_KEY=sk-...
export DEEPSEEK_BASE_URL=https://... # 可选的备用端点
export REASONIX_MEMORY=off # 关闭 REASONIX.md + 用户记忆
export REASONIX_SEARCH=off # 关闭 web_search / web_fetch
export REASONIX_UI=plain # 关闭实时行(鬼影绕开)
```
---
## 库用法
```ts
import {
CacheFirstLoop,
DeepSeekClient,
ImmutablePrefix,
ToolRegistry,
} from "reasonix";
const client = new DeepSeekClient(); // 从环境变量读 DEEPSEEK_API_KEY
const tools = new ToolRegistry();
tools.register({
name: "add",
description: "Add two integers",
parameters: {
type: "object",
properties: { a: { type: "integer" }, b: { type: "integer" } },
required: ["a", "b"],
},
fn: ({ a, b }: { a: number; b: number }) => a + b,
});
const loop = new CacheFirstLoop({
client,
tools,
prefix: new ImmutablePrefix({
system: "You are a math helper.",
toolSpecs: tools.specs(),
}),
harvest: true,
branch: 3,
});
for await (const ev of loop.step("What is 17 + 25?")) {
if (ev.role === "assistant_final") console.log(ev.content);
}
console.log(loop.stats.summary());
```
`ChatOptions.seedTools` 接受一个预置好的 `ToolRegistry`,如果你只想要
`reasonix code` 的 loop 接线、不要 CLI 包装。详见
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
---
## 性能对比 —— 缓存命中率自己也能验证
这里每个抽象都对应 DeepSeek 的一个具体特性 —— 极低 token 价、R1 推理轨
迹、自动前缀缓存、JSON 模式。通用框架把这些机会全留在桌上。
| | Reasonix 默认 | 通用框架 |
|---|---|---|
| 前缀稳定的循环(→ 85–95% 缓存命中) | 是 | 否(每轮重建 prompt) |
| 自动展平深层 tool schema | 是 | 否(DeepSeek 会丢参数) |
| 带 jitter 退避的 429/503 重试 | 是 | 否(要自己写回调) |
| 抢救泄到 `<think>` 里的 tool call | 是 | 否 |
| 同参数重复爆发熔断 | 是 | 否 |
| 实时缓存命中 / 成本 / vs Claude 面板 | 是 | 否 |
同一 τ-bench-lite 负载(8 个多轮工具调用任务 × 3 次重复 = 每边 48 次运行),
实测 DeepSeek `deepseek-chat`,唯一变量是前缀稳定性:
| 指标 | 基线(缓存敌对) | Reasonix | 差值 |
|---|---:|---:|---:|
| 缓存命中 | 46.6% | **94.4%** | +47.7 pp |
| 单任务成本 | $0.002599 | $0.001579 | **−39%** |
| 通过率 | 96% (23/24) | **100% (24/24)** | — |
**无需消耗 API 额度即可复现:**
```bash
git clone https://github.com/esengine/reasonix.git && cd reasonix && npm install
npx reasonix replay benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl
npx reasonix diff \
benchmarks/tau-bench/transcripts/t01_address_happy.baseline.r1.jsonl \
benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl
```
提交进仓库的 JSONL 文件每轮带 `usage`、`cost`、`prefixHash`。Reasonix
的前缀哈希在每次模型调用时都字节稳定;基线则每轮都变。缓存差距是从日志稳
定性 **机械可推导** 的,不依赖于 prompt 写得不一样。
完整 48 次运行报告:
[`benchmarks/tau-bench/report.md`](./benchmarks/tau-bench/report.md)。
用自己的 API key 复现:`npx tsx benchmarks/tau-bench/runner.ts --repeats 3`。
MCP 参考运行(即使两个 MCP 子进程并发,整 5 轮也只有一个前缀哈希):
| 服务器 | 轮次 | 缓存命中 | 成本 | vs Claude |
|---|---:|---:|---:|---:|
| 内置 demo(`add` / `echo` / `get_time`) | 2 | **96.6%**(第 2 轮) | $0.000254 | −94.0% |
| 官方 `server-filesystem` | 5 | **96.7%** | $0.001235 | −97.0% |
| **两者并发** | 5 | **81.1%** | $0.001852 | −95.9% |
---
## Non-goals(明确不做)
- **多 agent 编排 / 子 agent**(用 LangGraph)。
- **工作流 DSL / DAG 调度 / 并行分支引擎** —— skill 是散文,模型靠正常的
tool-use 循环串起来。这样保住了单循环 + append-only + cache-first 三大
不变量。
- **多供应商抽象**(用 LiteLLM)。Reasonix 故意只做 DeepSeek —— 每个支柱
(cache-first 循环、R1 抢救、tool-call 修复)都针对 DeepSeek 的具体行为
和经济性做了调优。绑死一家后端是特性。
- **RAG / 向量库**(用 LlamaIndex)。
- **Web UI / SaaS。**
Reasonix 只做 DeepSeek,做到底。
---
## 开发
```bash
git clone https://github.com/esengine/reasonix.git
cd reasonix
npm install
npm run dev code # 用 tsx 直接从源码跑 CLI
npm run build # tsup 打包到 dist/
npm test # vitest(1482 个测试)
npm run lint # biome
npm run typecheck # tsc --noEmit
```
---
## License
MIT
View File
Whitespace-only changes.
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="50%" stop-color="#93c5fd"/>
<stop offset="100%" stop-color="#c4b5fd"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0a0d14"/>
<path d="M 32,10 L 54,32 L 32,54 L 10,32 Z" fill="none" stroke="url(#g)" stroke-width="3" stroke-linejoin="round"/>
<path d="M 32,21 L 43,32 L 32,43 L 21,32 Z" fill="url(#g)" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

+406
View File
@@ -0,0 +1,406 @@
// Reasonix landing — i18n auto-switch (en / zh).
// Detection precedence: ?lang=xx → localStorage → navigator.language → "en".
// Falls back gracefully when localStorage is unavailable (private mode, etc).
(function () {
"use strict";
const STORAGE_KEY = "reasonix.lang";
const DEFAULT_LANG = "en";
const SUPPORTED = ["en", "zh"];
const translations = {
en: {
"nav.features": "Features",
"nav.quickstart": "Quick start",
"nav.benchmarks": "Benchmarks",
"nav.github": "GitHub",
"hero.badge": "v0.6 · DeepSeek V4 · cache-first",
"hero.title.line1": "DeepSeek-native",
"hero.title.line2": "AI coding agent in your terminal",
"hero.sub":
"Cache-first agent loop for DeepSeek V4 (flash + pro). Edits files as reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No LangChain.",
"hero.copy": "Copy",
"hero.copy.done": "Copied",
"hero.cta.start": "Get started →",
"hero.cta.star": "Star on GitHub",
"hero.stat.cache": "prefix cache hit rate",
"hero.stat.cost": "cheaper vs Claude Sonnet 4.6",
"hero.stat.pass": "τ-bench-lite pass rate",
"term.user":
"users.ts findByEmail is case-sensitive — login fails for users with uppercase emails",
"term.found":
"▸ Found it. findByEmail uses === directly. Switch to lowercase normalization.",
"term.pending": "▸ 1 pending edit · /apply to write · /discard to drop",
"why.title": "Why Reasonix",
"why.sub":
"Every abstraction earns its weight against a DeepSeek-specific property — dirt-cheap tokens, R1 traces, automatic prefix caching, JSON mode. Generic wrappers leave these on the table.",
"why.cache.title": "Cache-first loop",
"why.cache.body":
"Append-only conversation log keeps the prompt prefix byte-stable across turns, so DeepSeek's automatic cache hits 85–95% — not 40%.",
"why.cost.title": "Cost is a pillar",
"why.cost.body":
"Flash-first defaults, turn-end auto-compaction, model self-escalation when (and only when) needed. Per-turn cost badge in the TUI.",
"why.tui.title": "Ink TUI, no web",
"why.tui.body":
"Lives in your terminal. Streaming preview, slash commands, plan picker, edit-confirm modal. Plain mode for stubborn Windows shells.",
"why.mcp.title": "MCP first-class",
"why.mcp.body":
"Stdio + HTTP+SSE transports. Live progress notifications. Same cache-safety + repair plumbing as native tools.",
"why.safe.title": "Reviewable edits",
"why.safe.body":
"Edits arrive as SEARCH/REPLACE blocks; nothing hits disk until /apply. Per-file diff confirm modal, undo history, sandboxed paths.",
"why.ctx.title": "1M-token aware",
"why.ctx.body":
"Context gauge with proactive compaction, oversized tool-result repair, forced-summary fallback near the window edge.",
"qs.title": "Quick start (60 seconds)",
"qs.step1.title": "Get a DeepSeek API key",
"qs.step1.body":
'Free credit on signup at <a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener">platform.deepseek.com</a>.',
"qs.step2.title": "Point it at a project",
"qs.step2.body": "No install needed.",
"qs.step2.note":
"First run walks you through a 30-second wizard (paste API key → pick preset → pick MCP servers).",
"qs.step3.title": "Ask it to edit",
"qs.step3.body":
"The model proposes edits as SEARCH/REPLACE blocks — nothing hits disk until you <code>/apply</code>.",
"qs.req":
"Requires Node ≥ 20.10. macOS, Linux, Windows (PowerShell · Git Bash · Windows Terminal). Press <kbd>Esc</kbd> anytime to abort; <code>/help</code> for the full command list.",
"feat.title": "In the box",
"feat.code.title": "Pair programmer mode",
"feat.code.body":
"<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> · <code>search_files</code> · <code>directory_tree</code> · <code>run_command</code> with read-only allowlist. Sandboxed to the launch root — no path escapes. Plan mode and per-edit review modal.",
"feat.memory.title": "Two-layer memory",
"feat.memory.body":
"Committable <code>REASONIX.md</code> for team conventions plus a private per-user <code>~/.reasonix/memory/</code> with global and per-project scopes. The model can write to it via the <code>remember</code> tool.",
"feat.skills.title": "User-authored prompt packs",
"feat.skills.body":
"Drop <code>SKILL.md</code> files anywhere. Names + descriptions are pinned in the prefix; the model picks them autonomously, or you trigger with <code>/skill name</code>.",
"feat.hooks.title": "Lifecycle hooks",
"feat.hooks.body":
"Shell commands fire at <code>PreToolUse</code> · <code>PostToolUse</code> · <code>UserPromptSubmit</code> · <code>Stop</code>. Exit code drives pass / block / warn.",
"feat.mcp.title": "Bring your own tools",
"feat.mcp.body":
'Wizard catalog, or <code>--mcp "name=cmd"</code> to attach stdio / HTTP+SSE servers on the fly. Live progress bars on long calls.',
"feat.web.title": "Web search built-in",
"feat.web.body":
"<code>web_search</code> + <code>web_fetch</code> via Mojeek — no key, no signup, off-by-flag for offline / CI. Bring-your-own provider via the <code>WebSearchProvider</code> interface.",
"bench.title": "Verify the cache claim yourself",
"bench.sub":
"On the same τ-bench-lite workload (8 multi-turn tool-use tasks × 3 repeats = 48 runs per side), live DeepSeek <code>deepseek-chat</code>, sole variable prefix stability:",
"bench.col.metric": "metric",
"bench.col.baseline": "baseline (cache-hostile)",
"bench.col.reasonix": "Reasonix",
"bench.col.delta": "delta",
"bench.row.cache": "cache hit",
"bench.row.cost": "cost / task",
"bench.row.pass": "pass rate",
"bench.repro.intro": "Reproduce without spending an API credit:",
"bench.repro.note":
"The committed JSONL transcripts carry per-turn <code>usage</code>, <code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays byte-stable across every model call.",
"cli.title": "CLI at a glance",
"cli.code": "coding mode scoped to path",
"cli.chat": "chat (uses saved config)",
"cli.setup": "reconfigure the wizard",
"cli.run": "one-shot, streams to stdout",
"cli.stats": "cross-session cost dashboard",
"cli.mcp": "probe a single MCP server",
"cli.flags.intro": "Common flags:",
"cli.f.preset": "model + harvest + branch bundle",
"cli.f.model": "explicit DeepSeek model id",
"cli.f.mcp": "attach an MCP server (repeatable)",
"cli.f.session": "named session",
"cli.f.noconf": "ignore ~/.reasonix/config.json (CI)",
"ctab.title": "Ready to try?",
"ctab.sub": "One <code>npx</code> away. Sandboxed. Reviewable. 96% cheaper.",
"ctab.gh": "GitHub repository →",
"ctab.npm": "npm package",
"foot.tag": "DeepSeek does deep, deeply.",
"foot.col.project": "Project",
"foot.col.docs": "Docs",
"foot.col.community": "Community",
"foot.changelog": "Changelog",
"foot.readme": "README",
"foot.readme.zh": "中文 README",
"foot.arch": "Architecture",
"foot.issues": "Issues",
"foot.discuss": "Discussions",
"foot.copyright": "© 2026 Reasonix · MIT License",
},
zh: {
"nav.features": "特性",
"nav.quickstart": "快速上手",
"nav.benchmarks": "性能对比",
"nav.github": "GitHub",
"hero.badge": "v0.6 · DeepSeek V4 · 缓存优先",
"hero.title.line1": "DeepSeek 原生",
"hero.title.line2": "终端里的 AI 编程代理",
"hero.sub":
"为 DeepSeek V4(flash + pro)打造的缓存优先 agent 循环。编辑以可审查的 SEARCH/REPLACE 块呈现,落盘前必须确认。Ink TUI、原生支持 MCP,不依赖 LangChain。",
"hero.copy": "复制",
"hero.copy.done": "已复制",
"hero.cta.start": "开始使用 →",
"hero.cta.star": "在 GitHub 加星",
"hero.stat.cache": "前缀缓存命中率",
"hero.stat.cost": "相比 Claude Sonnet 4.6 节省",
"hero.stat.pass": "τ-bench-lite 通过率",
"term.user":
"users.ts 里 findByEmail 对大小写敏感导致登录失败,帮我改",
"term.found":
"▸ 找到了。findByEmail 直接用 === 比对。改成小写规范化并补一条测试。",
"term.pending": "▸ 1 处待应用编辑 · /apply 写入 · /discard 丢弃",
"why.title": "为什么选 Reasonix",
"why.sub":
"每个抽象都对应 DeepSeek 的一个具体特性——极低 token 价、R1 推理轨迹、自动前缀缓存、JSON 模式。通用框架把这些机会全留在桌上。",
"why.cache.title": "缓存优先的循环",
"why.cache.body":
"对话日志只追加不重写,前缀字节级稳定,DeepSeek 的自动前缀缓存命中能稳定打到 85–95%,而不是 40%。",
"why.cost.title": "成本是头等公民",
"why.cost.body":
"默认 flash 优先、turn 末尾自动压缩、必要时模型自我升级到 pro。TUI 有实时单轮成本徽章。",
"why.tui.title": "Ink TUI,不靠浏览器",
"why.tui.body":
"完全在终端里。流式预览、斜杠命令、计划审阅、编辑确认弹窗。Windows 顽固终端可启用 plain 模式。",
"why.mcp.title": "原生 MCP",
"why.mcp.body":
"stdio + HTTP+SSE 双传输。长任务有实时进度条。和原生工具一样走缓存安全 + 自动修复管线。",
"why.safe.title": "编辑可审阅",
"why.safe.body":
"编辑以 SEARCH/REPLACE 块呈现,/apply 之前不动磁盘。逐文件 diff 确认、撤销历史、路径沙箱。",
"why.ctx.title": "百万 token 感知",
"why.ctx.body":
"上下文仪表 + 主动压缩、超大工具结果自动修复、贴近上限自动总结兜底。",
"qs.title": "60 秒快速上手",
"qs.step1.title": "获取 DeepSeek API Key",
"qs.step1.body":
'在 <a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener">platform.deepseek.com</a> 注册即送免费额度。',
"qs.step2.title": "切到项目目录运行",
"qs.step2.body": "无需安装。",
"qs.step2.note":
"首次运行会走 30 秒向导:粘贴 API key → 选预设 → 勾选 MCP 服务器。",
"qs.step3.title": "让它改代码",
"qs.step3.body":
"模型会以 SEARCH/REPLACE 块的形式提出编辑——你不 <code>/apply</code>,磁盘不会被改。",
"qs.req":
"需要 Node ≥ 20.10。支持 macOS、Linux、Windows(PowerShell · Git Bash · Windows Terminal)。任何时候按 <kbd>Esc</kbd> 中断;<code>/help</code> 查看完整命令。",
"feat.title": "开箱即用",
"feat.code.title": "结对编程模式",
"feat.code.body":
"<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> · <code>search_files</code> · <code>directory_tree</code> · 带只读白名单的 <code>run_command</code>。沙箱限定在启动目录——路径逃逸一律拒绝。支持 plan 模式与逐次编辑确认弹窗。",
"feat.memory.title": "两层记忆系统",
"feat.memory.body":
"可提交的 <code>REASONIX.md</code> 承载团队约定,私人 <code>~/.reasonix/memory/</code> 分全局与按项目两个作用域。模型可通过 <code>remember</code> 工具自行写入。",
"feat.skills.title": "用户定义的 prompt 包",
"feat.skills.body":
"把 <code>SKILL.md</code> 丢进任意位置。名字 + 描述会被钉在前缀里,模型可自动调用,或你用 <code>/skill name</code> 手动触发。",
"feat.hooks.title": "生命周期钩子",
"feat.hooks.body":
"在 <code>PreToolUse</code> · <code>PostToolUse</code> · <code>UserPromptSubmit</code> · <code>Stop</code> 四个点触发 shell 命令,退出码决定放行 / 阻断 / 警告。",
"feat.mcp.title": "自带工具进来",
"feat.mcp.body":
'向导内置目录,或用 <code>--mcp "name=cmd"</code> 直接挂 stdio / HTTP+SSE 服务器。长任务渲染实时进度条。',
"feat.web.title": "内置联网搜索",
"feat.web.body":
"<code>web_search</code> + <code>web_fetch</code>,背靠 Mojeek——无需 key、无需注册,离线 / CI 可关闭。可通过 <code>WebSearchProvider</code> 接口接入自家搜索。",
"bench.title": "缓存命中率自己也能验证",
"bench.sub":
"同一 τ-bench-lite 负载(8 个多轮工具调用任务 × 3 次重复 = 每边 48 次运行),实测 DeepSeek <code>deepseek-chat</code>,唯一变量是前缀稳定性:",
"bench.col.metric": "指标",
"bench.col.baseline": "基线(缓存敌对)",
"bench.col.reasonix": "Reasonix",
"bench.col.delta": "差值",
"bench.row.cache": "缓存命中",
"bench.row.cost": "单任务成本",
"bench.row.pass": "通过率",
"bench.repro.intro": "无需消耗 API 额度即可复现:",
"bench.repro.note":
"提交进仓库的 JSONL 文件每轮带 <code>usage</code>、<code>cost</code>、<code>prefixHash</code>。Reasonix 的前缀哈希在每次模型调用时都字节稳定。",
"cli.title": "CLI 速览",
"cli.code": "针对指定路径的编程模式",
"cli.chat": "聊天模式(读取已保存配置)",
"cli.setup": "重新跑配置向导",
"cli.run": "一次性运行,结果流到 stdout",
"cli.stats": "跨会话的成本仪表盘",
"cli.mcp": "探测单个 MCP 服务器",
"cli.flags.intro": "常用 flag:",
"cli.f.preset": "模型 + harvest + 分支并行 一键组合",
"cli.f.model": "显式指定 DeepSeek 模型 ID",
"cli.f.mcp": "挂载 MCP 服务器(可重复)",
"cli.f.session": "命名会话",
"cli.f.noconf": "忽略 ~/.reasonix/config.json(CI 友好)",
"ctab.title": "准备好了吗?",
"ctab.sub": "一条 <code>npx</code> 即可开始。沙箱、可审阅、便宜 96%。",
"ctab.gh": "GitHub 仓库 →",
"ctab.npm": "npm 包",
"foot.tag": "Reasonix 只做 DeepSeek,做到底。",
"foot.col.project": "项目",
"foot.col.docs": "文档",
"foot.col.community": "社区",
"foot.changelog": "更新日志",
"foot.readme": "英文 README",
"foot.readme.zh": "中文 README",
"foot.arch": "架构文档",
"foot.issues": "问题反馈",
"foot.discuss": "讨论区",
"foot.copyright": "© 2026 Reasonix · MIT 协议",
},
};
function safeStorageGet(key) {
try {
return window.localStorage.getItem(key);
} catch (_) {
return null;
}
}
function safeStorageSet(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (_) {
/* ignore */
}
}
function detectLang() {
const params = new URLSearchParams(window.location.search);
const queryLang = params.get("lang");
if (queryLang && SUPPORTED.includes(queryLang)) return queryLang;
const stored = safeStorageGet(STORAGE_KEY);
if (stored && SUPPORTED.includes(stored)) return stored;
const navLang = (navigator.language || navigator.userLanguage || "").toLowerCase();
if (navLang.startsWith("zh")) return "zh";
if (Array.isArray(navigator.languages)) {
for (const l of navigator.languages) {
if (l && l.toLowerCase().startsWith("zh")) return "zh";
}
}
return DEFAULT_LANG;
}
let currentLang = DEFAULT_LANG;
const langListeners = [];
function applyLang(lang) {
if (!SUPPORTED.includes(lang)) lang = DEFAULT_LANG;
const changed = lang !== currentLang;
currentLang = lang;
const dict = translations[lang];
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
document.documentElement.setAttribute("data-lang", lang);
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
if (dict[key] !== undefined) {
el.innerHTML = dict[key];
}
});
document.querySelectorAll("[data-lang-btn]").forEach((btn) => {
const isActive = btn.getAttribute("data-lang-btn") === lang;
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
});
safeStorageSet(STORAGE_KEY, lang);
if (changed) {
for (const fn of langListeners) {
try {
fn(lang);
} catch (_) {
/* ignore */
}
}
}
}
// Public API for sibling scripts (term-anim.js).
window.Reasonix = window.Reasonix || {};
window.Reasonix.t = function (key) {
const dict = translations[currentLang] || translations[DEFAULT_LANG];
return dict[key];
};
window.Reasonix.lang = function () {
return currentLang;
};
window.Reasonix.onLangChange = function (fn) {
if (typeof fn === "function") langListeners.push(fn);
};
function wireLangButtons() {
document.querySelectorAll("[data-lang-btn]").forEach((btn) => {
btn.addEventListener("click", () => {
applyLang(btn.getAttribute("data-lang-btn"));
});
});
}
function wireCopyButtons() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const text = btn.getAttribute("data-copy");
try {
await navigator.clipboard.writeText(text);
} catch (_) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch (_) {
/* ignore */
}
document.body.removeChild(ta);
}
const lang = document.documentElement.getAttribute("data-lang") || DEFAULT_LANG;
const original = translations[lang]["hero.copy"] || "Copy";
const done = translations[lang]["hero.copy.done"] || "Copied";
btn.textContent = done;
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = original;
btn.classList.remove("copied");
}, 1600);
});
});
}
function init() {
applyLang(detectLang());
wireLangButtons();
wireCopyButtons();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+493
View File
@@ -0,0 +1,493 @@
<!doctype html>
<html lang="en" data-lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reasonix — DeepSeek-native AI coding agent</title>
<meta
name="description"
content="Cache-first agent loop for DeepSeek V4. Ink TUI. MCP first-class. 94%+ prefix cache hit, ~96% cheaper than Claude on the same workload. No LangChain."
/>
<meta name="theme-color" content="#0b0f17" />
<meta property="og:title" content="Reasonix — DeepSeek-native AI coding agent" />
<meta
property="og:description"
content="Cache-first agent loop for DeepSeek V4 (flash + pro). Ink TUI. MCP first-class. No LangChain."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://esengine.github.io/reasonix/" />
<meta property="og:image" content="https://raw.githubusercontent.com/esengine/reasonix/main/docs/logo.svg" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="bg-glow" aria-hidden="true"></div>
<header class="nav">
<a class="nav-brand" href="#top" aria-label="Reasonix">
<span class="brand-mark" aria-hidden="true">
<span class="diamond"></span>
<span class="diamond inner"></span>
</span>
<span class="brand-name">Reasonix</span>
</a>
<nav class="nav-links">
<a href="#features" data-i18n="nav.features">Features</a>
<a href="#quickstart" data-i18n="nav.quickstart">Quick start</a>
<a href="#benchmarks" data-i18n="nav.benchmarks">Benchmarks</a>
<a
href="https://github.com/esengine/reasonix"
target="_blank"
rel="noopener"
data-i18n="nav.github"
>GitHub</a
>
</nav>
<div class="nav-actions">
<div class="lang-switch" role="group" aria-label="Language">
<button data-lang-btn="en" type="button" aria-pressed="true">EN</button>
<button data-lang-btn="zh" type="button" aria-pressed="false">中文</button>
</div>
</div>
</header>
<main id="top">
<section class="hero">
<div class="hero-inner">
<div class="badge" data-i18n="hero.badge">v0.6 · DeepSeek V4 · cache-first</div>
<h1 class="hero-title">
<span class="grad-text" data-i18n="hero.title.line1">DeepSeek-native</span>
<br />
<span data-i18n="hero.title.line2">AI coding agent in your terminal</span>
</h1>
<p class="hero-sub" data-i18n="hero.sub">
Cache-first agent loop for DeepSeek V4 (flash + pro). Edits files as
reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No LangChain.
</p>
<div class="hero-install">
<code class="install-cmd"
><span class="prompt">$</span> <span class="cmd">npx reasonix code</span></code
>
<button class="copy-btn" data-copy="npx reasonix code" data-i18n="hero.copy">
Copy
</button>
</div>
<div class="hero-ctas">
<a class="cta primary" href="#quickstart" data-i18n="hero.cta.start">
Get started →
</a>
<a
class="cta ghost"
href="https://github.com/esengine/reasonix"
target="_blank"
rel="noopener"
data-i18n="hero.cta.star"
>Star on GitHub</a
>
</div>
<div class="hero-stats">
<div class="stat">
<div class="stat-num grad-text">94.4%</div>
<div class="stat-label" data-i18n="hero.stat.cache">prefix cache hit rate</div>
</div>
<div class="stat">
<div class="stat-num grad-text">~96%</div>
<div class="stat-label" data-i18n="hero.stat.cost">cheaper vs Claude Sonnet 4.6</div>
</div>
<div class="stat">
<div class="stat-num grad-text">100%</div>
<div class="stat-label" data-i18n="hero.stat.pass">τ-bench-lite pass rate</div>
</div>
</div>
</div>
<div class="hero-terminal" aria-hidden="true">
<div class="term-bar">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<span class="term-title">reasonix code</span>
<button class="term-replay" type="button" data-replay aria-label="replay">↻</button>
</div>
<div class="term-body" id="term-body" data-anim-root></div>
</div>
</section>
<section class="why">
<div class="container">
<h2 class="section-title" data-i18n="why.title">Why Reasonix</h2>
<p class="section-sub" data-i18n="why.sub">
Every abstraction earns its weight against a DeepSeek-specific property —
dirt-cheap tokens, R1 traces, automatic prefix caching, JSON mode. Generic
wrappers leave these on the table.
</p>
<div class="why-grid">
<article class="why-card">
<div class="why-icon">◈</div>
<h3 data-i18n="why.cache.title">Cache-first loop</h3>
<p data-i18n="why.cache.body">
Append-only conversation log keeps the prompt prefix byte-stable across
turns, so DeepSeek's automatic cache hits 85–95% — not 40%.
</p>
</article>
<article class="why-card">
<div class="why-icon">¥</div>
<h3 data-i18n="why.cost.title">Cost is a pillar</h3>
<p data-i18n="why.cost.body">
Flash-first defaults, turn-end auto-compaction, model self-escalation
when (and only when) needed. Per-turn cost badge in the TUI.
</p>
</article>
<article class="why-card">
<div class="why-icon">⌨</div>
<h3 data-i18n="why.tui.title">Ink TUI, no web</h3>
<p data-i18n="why.tui.body">
Lives in your terminal. Streaming preview, slash commands, plan picker,
edit-confirm modal. Plain mode for stubborn Windows shells.
</p>
</article>
<article class="why-card">
<div class="why-icon">⊕</div>
<h3 data-i18n="why.mcp.title">MCP first-class</h3>
<p data-i18n="why.mcp.body">
Stdio + HTTP+SSE transports. Live progress notifications. Same
cache-safety + repair plumbing as native tools.
</p>
</article>
<article class="why-card">
<div class="why-icon">↶</div>
<h3 data-i18n="why.safe.title">Reviewable edits</h3>
<p data-i18n="why.safe.body">
Edits arrive as SEARCH/REPLACE blocks; nothing hits disk until /apply.
Per-file diff confirm modal, undo history, sandboxed paths.
</p>
</article>
<article class="why-card">
<div class="why-icon">∞</div>
<h3 data-i18n="why.ctx.title">1M-token aware</h3>
<p data-i18n="why.ctx.body">
Context gauge with proactive compaction, oversized tool-result repair,
forced-summary fallback near the window edge.
</p>
</article>
</div>
</div>
</section>
<section id="quickstart" class="quickstart">
<div class="container">
<h2 class="section-title" data-i18n="qs.title">Quick start (60 seconds)</h2>
<ol class="qs-steps">
<li>
<h3 data-i18n="qs.step1.title">Get a DeepSeek API key</h3>
<p data-i18n="qs.step1.body">
Free credit on signup at
<a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener"
>platform.deepseek.com</a
>.
</p>
</li>
<li>
<h3 data-i18n="qs.step2.title">Point it at a project</h3>
<p data-i18n="qs.step2.body">No install needed.</p>
<pre class="code"><code>cd my-project
npx reasonix code</code></pre>
<p data-i18n="qs.step2.note">
First run walks you through a 30-second wizard (paste API key → pick
preset → pick MCP servers).
</p>
</li>
<li>
<h3 data-i18n="qs.step3.title">Ask it to edit</h3>
<p data-i18n="qs.step3.body">
The model proposes edits as SEARCH/REPLACE blocks — nothing hits disk
until you <code>/apply</code>.
</p>
</li>
</ol>
<p class="req" data-i18n="qs.req">
Requires Node ≥ 20.10. macOS, Linux, Windows (PowerShell · Git Bash · Windows
Terminal). Press <kbd>Esc</kbd> anytime to abort; <code>/help</code> for the
full command list.
</p>
</div>
</section>
<section id="features" class="features">
<div class="container">
<h2 class="section-title" data-i18n="feat.title">In the box</h2>
<div class="feat-grid">
<article class="feat">
<h3>
<span class="feat-tag">code</span>
<span data-i18n="feat.code.title">Pair programmer mode</span>
</h3>
<p data-i18n="feat.code.body">
<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> ·
<code>search_files</code> · <code>directory_tree</code> ·
<code>run_command</code> with read-only allowlist. Sandboxed to the
launch root — no path escapes. Plan mode and per-edit review modal.
</p>
</article>
<article class="feat">
<h3>
<span class="feat-tag">memory</span>
<span data-i18n="feat.memory.title">Two-layer memory</span>
</h3>
<p data-i18n="feat.memory.body">
Committable <code>REASONIX.md</code> for team conventions plus a private
per-user <code>~/.reasonix/memory/</code> with global and per-project
scopes. The model can write to it via the <code>remember</code> tool.
</p>
</article>
<article class="feat">
<h3>
<span class="feat-tag">skills</span>
<span data-i18n="feat.skills.title">User-authored prompt packs</span>
</h3>
<p data-i18n="feat.skills.body">
Drop <code>SKILL.md</code> files anywhere. Names + descriptions are
pinned in the prefix; the model picks them autonomously, or you trigger
with <code>/skill name</code>.
</p>
</article>
<article class="feat">
<h3>
<span class="feat-tag">hooks</span>
<span data-i18n="feat.hooks.title">Lifecycle hooks</span>
</h3>
<p data-i18n="feat.hooks.body">
Shell commands fire at <code>PreToolUse</code> · <code>PostToolUse</code>
· <code>UserPromptSubmit</code> · <code>Stop</code>. Exit code drives
pass / block / warn.
</p>
</article>
<article class="feat">
<h3>
<span class="feat-tag">mcp</span>
<span data-i18n="feat.mcp.title">Bring your own tools</span>
</h3>
<p data-i18n="feat.mcp.body">
Wizard catalog, or <code>--mcp "name=cmd"</code> to attach stdio /
HTTP+SSE servers on the fly. Live progress bars on long calls.
</p>
</article>
<article class="feat">
<h3>
<span class="feat-tag">web</span>
<span data-i18n="feat.web.title">Web search built-in</span>
</h3>
<p data-i18n="feat.web.body">
<code>web_search</code> + <code>web_fetch</code> via Mojeek — no key, no
signup, off-by-flag for offline / CI. Bring-your-own provider via the
<code>WebSearchProvider</code> interface.
</p>
</article>
</div>
</div>
</section>
<section id="benchmarks" class="bench">
<div class="container">
<h2 class="section-title" data-i18n="bench.title">Verify the cache claim yourself</h2>
<p class="section-sub" data-i18n="bench.sub">
On the same τ-bench-lite workload (8 multi-turn tool-use tasks × 3 repeats =
48 runs per side), live DeepSeek <code>deepseek-chat</code>, sole variable
prefix stability:
</p>
<div class="bench-table-wrap">
<table class="bench-table">
<thead>
<tr>
<th data-i18n="bench.col.metric">metric</th>
<th data-i18n="bench.col.baseline">baseline (cache-hostile)</th>
<th data-i18n="bench.col.reasonix">Reasonix</th>
<th data-i18n="bench.col.delta">delta</th>
</tr>
</thead>
<tbody>
<tr>
<td data-i18n="bench.row.cache">cache hit</td>
<td>46.6%</td>
<td class="hi">94.4%</td>
<td>+47.7 pp</td>
</tr>
<tr>
<td data-i18n="bench.row.cost">cost / task</td>
<td>$0.002599</td>
<td class="hi">$0.001579</td>
<td><strong>−39%</strong></td>
</tr>
<tr>
<td data-i18n="bench.row.pass">pass rate</td>
<td>96% (23/24)</td>
<td class="hi">100% (24/24)</td>
<td>—</td>
</tr>
</tbody>
</table>
</div>
<p data-i18n="bench.repro.intro">Reproduce without spending an API credit:</p>
<pre class="code"><code>git clone https://github.com/esengine/reasonix.git &amp;&amp; cd reasonix &amp;&amp; npm install
npx reasonix replay benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl
npx reasonix diff \
benchmarks/tau-bench/transcripts/t01_address_happy.baseline.r1.jsonl \
benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl</code></pre>
<p data-i18n="bench.repro.note">
The committed JSONL transcripts carry per-turn <code>usage</code>,
<code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays
byte-stable across every model call.
</p>
</div>
</section>
<section class="cli">
<div class="container">
<h2 class="section-title" data-i18n="cli.title">CLI at a glance</h2>
<pre class="code"><code>npx reasonix code [path] <span class="hash"># <span data-i18n="cli.code">coding mode scoped to path</span></span>
npx reasonix <span class="hash"># <span data-i18n="cli.chat">chat (uses saved config)</span></span>
npx reasonix setup <span class="hash"># <span data-i18n="cli.setup">reconfigure the wizard</span></span>
npx reasonix run "ask anything" <span class="hash"># <span data-i18n="cli.run">one-shot, streams to stdout</span></span>
npx reasonix stats <span class="hash"># <span data-i18n="cli.stats">cross-session cost dashboard</span></span>
npx reasonix mcp inspect &lt;spec&gt; <span class="hash"># <span data-i18n="cli.mcp">probe a single MCP server</span></span></code></pre>
<p class="cli-flags" data-i18n="cli.flags.intro">Common flags:</p>
<pre class="code"><code>--preset &lt;fast|smart|max&gt; <span class="hash"># <span data-i18n="cli.f.preset">model + harvest + branch bundle</span></span>
--model &lt;id&gt; <span class="hash"># <span data-i18n="cli.f.model">explicit DeepSeek model id</span></span>
--mcp "name=cmd args…" <span class="hash"># <span data-i18n="cli.f.mcp">attach an MCP server (repeatable)</span></span>
--session &lt;name&gt; <span class="hash"># <span data-i18n="cli.f.session">named session</span></span>
--no-config <span class="hash"># <span data-i18n="cli.f.noconf">ignore ~/.reasonix/config.json (CI)</span></span></code></pre>
</div>
</section>
<section class="cta-band">
<div class="container">
<h2 data-i18n="ctab.title">Ready to try?</h2>
<p data-i18n="ctab.sub">
One <code>npx</code> away. Sandboxed. Reviewable. 96% cheaper.
</p>
<div class="hero-ctas center">
<a
class="cta primary"
href="https://github.com/esengine/reasonix"
target="_blank"
rel="noopener"
data-i18n="ctab.gh"
>GitHub repository →</a
>
<a
class="cta ghost"
href="https://www.npmjs.com/package/reasonix"
target="_blank"
rel="noopener"
data-i18n="ctab.npm"
>npm package</a
>
</div>
</div>
</section>
</main>
<footer class="foot">
<div class="container foot-inner">
<div>
<a href="#top" class="nav-brand">
<span class="brand-mark small" aria-hidden="true">
<span class="diamond"></span>
<span class="diamond inner"></span>
</span>
<span class="brand-name">Reasonix</span>
</a>
<p class="foot-tag" data-i18n="foot.tag">DeepSeek does deep, deeply.</p>
</div>
<div class="foot-cols">
<div>
<h4 data-i18n="foot.col.project">Project</h4>
<a href="https://github.com/esengine/reasonix" target="_blank" rel="noopener">GitHub</a>
<a href="https://www.npmjs.com/package/reasonix" target="_blank" rel="noopener">npm</a>
<a
href="https://github.com/esengine/reasonix/blob/main/CHANGELOG.md"
target="_blank"
rel="noopener"
data-i18n="foot.changelog"
>Changelog</a
>
<a
href="https://github.com/esengine/reasonix/blob/main/LICENSE"
target="_blank"
rel="noopener"
>MIT</a
>
</div>
<div>
<h4 data-i18n="foot.col.docs">Docs</h4>
<a
href="https://github.com/esengine/reasonix#readme"
target="_blank"
rel="noopener"
data-i18n="foot.readme"
>README</a
>
<a
href="https://github.com/esengine/reasonix/blob/main/README.zh-CN.md"
target="_blank"
rel="noopener"
data-i18n="foot.readme.zh"
>中文 README</a
>
<a
href="https://github.com/esengine/reasonix/blob/main/docs/ARCHITECTURE.md"
target="_blank"
rel="noopener"
data-i18n="foot.arch"
>Architecture</a
>
</div>
<div>
<h4 data-i18n="foot.col.community">Community</h4>
<a
href="https://github.com/esengine/reasonix/issues"
target="_blank"
rel="noopener"
data-i18n="foot.issues"
>Issues</a
>
<a
href="https://github.com/esengine/reasonix/discussions"
target="_blank"
rel="noopener"
data-i18n="foot.discuss"
>Discussions</a
>
</div>
</div>
</div>
<div class="foot-bottom">
<span data-i18n="foot.copyright">© 2026 Reasonix · MIT License</span>
</div>
</footer>
<script src="i18n.js"></script>
<script src="term-anim.js"></script>
</body>
</html>
+1189
View File
File diff suppressed because it is too large. Load diff
+404
View File
@@ -0,0 +1,404 @@
// Hero terminal animation — simulates a `reasonix code` session using
// the real TUI rendering primitives:
// ◇ / ◆ role glyphs + colored vertical accent bars
// ` ✓ tool_name ` pills (yellow bg, black text)
// EditBlockRow: rounded cyan border, filename, `- old` red / `+ new` green
// info rows: dim glyph + dim body (slate)
//
// Reads i18n strings via Reasonix.t(); restarts on language toggle.
(function () {
"use strict";
const ROOT_SEL = "[data-anim-root]";
const REPLAY_SEL = "[data-replay]";
const tr = (key, fallback) => {
if (window.Reasonix && typeof window.Reasonix.t === "function") {
const v = window.Reasonix.t(key);
if (v !== undefined && v !== null && v !== "") return v;
}
return fallback != null ? fallback : key;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
// Brand gradient — same stops as src/cli/ui/theme.ts GRADIENT.
const GRADIENT = [
"#5eead4",
"#67e8f9",
"#7dd3fc",
"#93c5fd",
"#a5b4fc",
"#c4b5fd",
"#d8b4fe",
"#f0abfc",
];
// ──────────────────────────────────────────────────────────────────
// Header bar — `◈ REASONIX v0.6.0 v4-flash REVIEW max … turn 1 · /help`
// ──────────────────────────────────────────────────────────────────
function buildHeader(turn) {
const h = el("div", "thead");
h.innerHTML =
'<span class="tw-mark">◈</span>' +
'<span class="tw-name">REASONIX</span>' +
'<span class="tw-ver">v0.6.0</span>' +
'<span class="tw-model">v4-flash</span>' +
'<span class="tw-pill review">REVIEW</span>' +
'<span class="tw-effort">max</span>' +
'<span class="tw-spacer"></span>' +
'<span class="tw-turn">turn ' +
turn +
" · /help</span>";
return h;
}
// ──────────────────────────────────────────────────────────────────
// User row — ◇ glyph (cyan) + cyan vertical accent bar + text.
// Mirrors EventLog.tsx role="user" render.
// ──────────────────────────────────────────────────────────────────
function buildUserRow(text) {
const row = el("div", "trow trow-user");
row.innerHTML =
'<span class="trole role-user">◇</span>' +
'<span class="tbar-cyan">▎</span>' +
'<span class="trow-body"></span>';
row.querySelector(".trow-body").textContent = text;
return row;
}
// Same shape as a user row but the body content is built progressively
// by the animation. Returns the row + an `input` ref + a `caret` span.
function buildLiveUserRow() {
const row = el("div", "trow trow-user trow-live");
row.innerHTML =
'<span class="trole role-user">◇</span>' +
'<span class="tbar-cyan">▎</span>' +
'<span class="trow-body"><span class="tinput"></span><span class="tcaret">▍</span></span>';
row._input = row.querySelector(".tinput");
row._caret = row.querySelector(".tcaret");
return row;
}
// ──────────────────────────────────────────────────────────────────
// Tool pill row — ` ✓ tool_name ` duration dim summary /tool N
// Yellow bg pill (red bg for errors). Mirrors ToolPill in EventLog.tsx.
// ──────────────────────────────────────────────────────────────────
function buildToolRow(name, summary, durationLabel, indexHint) {
const row = el("div", "trow trow-tool");
const pill = el("span", "tpill tpill-ok");
pill.textContent = " ✓ " + name + " ";
row.appendChild(pill);
if (durationLabel) {
row.appendChild(el("span", "tdim", " " + durationLabel));
}
row.appendChild(el("span", "tdim", " " + summary));
if (indexHint) {
row.appendChild(el("span", "tdim tindex", " /tool " + indexHint));
}
return row;
}
// ──────────────────────────────────────────────────────────────────
// Assistant row — ◆ glyph + ` v4-flash ` pill, then a green-bordered
// body the caller fills with text + (optionally) an EditBlockRow.
// Returns { row, body } so the caller can append into body.
// ──────────────────────────────────────────────────────────────────
function buildAssistantRow() {
const row = el("div", "trow trow-asst");
row.innerHTML =
'<div class="trow-asst-head">' +
'<span class="trole role-asst">◆</span>' +
'<span class="tpill tpill-model"> v4-flash </span>' +
"</div>" +
'<div class="trow-asst-body">' +
'<span class="tbar-green">▎</span>' +
'<div class="trow-asst-content"></div>' +
"</div>";
return { row, body: row.querySelector(".trow-asst-content") };
}
// ──────────────────────────────────────────────────────────────────
// EditBlockRow — rounded cyan border, filename in cyan bold, then
// `- old` red and `+ new` green lines. NO literal SEARCH/REPLACE
// markers (the model's text format is parsed; only the diff is
// shown). Mirrors EditBlockRow in markdown.tsx.
// ──────────────────────────────────────────────────────────────────
function buildEditBlock(filename, oldLines, newLines) {
const box = el("div", "tedit");
const head = el("div", "tedit-head");
head.appendChild(el("span", "tedit-filename", filename));
box.appendChild(head);
const diff = el("div", "tedit-diff");
box.appendChild(diff);
return { box, diff, oldLines, newLines };
}
// ──────────────────────────────────────────────────────────────────
// Info row — colored glyph + dim body. Used for pending + applied
// status lines, mirrors EventLog.tsx role="info".
// ──────────────────────────────────────────────────────────────────
function buildInfoRow(glyph, glyphColor, body) {
const row = el("div", "trow trow-info");
const g = el("span", "tinfo-glyph", glyph);
g.style.color = glyphColor;
row.appendChild(g);
row.appendChild(el("span", "tdim", " " + body));
return row;
}
// ──────────────────────────────────────────────────────────────────
// Animate text into a target node, character by character.
// ──────────────────────────────────────────────────────────────────
async function typeInto(target, text, perChar, cancelled) {
for (let i = 0; i < text.length; i++) {
target.appendChild(document.createTextNode(text[i]));
const j = perChar * (0.85 + Math.random() * 0.3);
const ch = text[i];
const pause = /[,。、;:,.;:]/.test(ch) ? perChar * 4 : 0;
// eslint-disable-next-line no-await-in-loop
await sleep(j + pause);
if (cancelled && cancelled()) return;
}
}
// Cancellation token — interrupts in-flight cycles when the user
// toggles language or clicks replay so we don't double-render.
let token = 0;
async function runCycle(root) {
const myToken = ++token;
const cancelled = () => myToken !== token;
root.classList.remove("trun-fade");
root.innerHTML = "";
root.appendChild(buildHeader(1));
await sleep(400);
if (cancelled()) return;
// 1. Live user prompt — types the question, then converts to a
// permanent user row on submit.
const userMsg = tr(
"term.user",
"users.ts findByEmail is case-sensitive — login fails"
);
const live = buildLiveUserRow();
root.appendChild(live);
await sleep(400);
if (cancelled()) return;
await typeInto(live._input, userMsg, 32, cancelled);
if (cancelled()) return;
await sleep(550);
live._caret.remove();
live.classList.add("trow-sent");
// 2. Tool pills appear one by one. Real summaries come from
// summarizeToolResult() in summarize.ts — we hard-code the
// representative output for this scene.
await sleep(380);
if (cancelled()) return;
root.appendChild(
buildToolRow(
"search_files",
"matched 2 files: src/users.ts, src/users.test.ts",
"0.4s",
"1"
)
);
await sleep(560);
if (cancelled()) return;
root.appendChild(
buildToolRow("read_file", "read src/users.ts (412 chars)", "0.2s", "2")
);
await sleep(640);
if (cancelled()) return;
// 3. Assistant row — ◆ + model pill, then green-bordered body
// containing the streamed text and the EditBlockRow.
const a = buildAssistantRow();
root.appendChild(a.row);
await sleep(280);
if (cancelled()) return;
const replyP = el("p", "tmd-p");
a.body.appendChild(replyP);
const replyMsg = tr(
"term.found",
"Found it. findByEmail uses === directly. Switching to lowercase normalization."
);
await typeInto(replyP, replyMsg, 18, cancelled);
if (cancelled()) return;
await sleep(360);
// 4. EditBlockRow — rounded cyan border with filename + colored
// diff lines. No SEARCH/REPLACE markers.
const eb = buildEditBlock(
"src/users.ts",
[" return users.find(u => u.email === email);"],
[
" const needle = email.toLowerCase();",
" return users.find(u => u.email.toLowerCase() === needle);",
]
);
a.body.appendChild(eb.box);
await sleep(220);
if (cancelled()) return;
for (const line of eb.oldLines) {
const r = el("div", "tdiff tdiff-old", "- " + line);
eb.diff.appendChild(r);
// eslint-disable-next-line no-await-in-loop
await sleep(180);
if (cancelled()) return;
}
for (const line of eb.newLines) {
const r = el("div", "tdiff tdiff-new", "+ " + line);
eb.diff.appendChild(r);
// eslint-disable-next-line no-await-in-loop
await sleep(180);
if (cancelled()) return;
}
await sleep(420);
if (cancelled()) return;
// 5. Pending info row — slate `▸` + dim body. Real text from
// formatPendingPreview() in edit-history.ts.
root.appendChild(
buildInfoRow(
"▸",
"#94a3b8",
tr(
"term.pending",
"1 pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop"
)
)
);
await sleep(1700);
if (cancelled()) return;
// 6. /apply — second user turn. Live row, types `/apply`, then
// transforms to a sent row. No tool pills (slash is local).
const apply = buildLiveUserRow();
root.appendChild(apply);
await sleep(420);
if (cancelled()) return;
await typeInto(apply._input, "/apply", 75, cancelled);
if (cancelled()) return;
await sleep(380);
apply._caret.remove();
apply.classList.add("trow-sent");
// 7. Applied info rows — first the header, then the per-file line.
// Mirrors formatEditResults() in edit-history.ts.
await sleep(420);
root.appendChild(
buildInfoRow(
"▸",
"#94a3b8",
"edit blocks: 1/1 applied — /undo to roll back, or `git diff` to review"
)
);
const ok = el("div", "trow trow-info trow-info-detail");
const gl = el("span", "tinfo-glyph", "✓");
gl.style.color = "#4ade80";
ok.appendChild(el("span", "tdim", " "));
ok.appendChild(gl);
ok.appendChild(el("span", "tdim", " applied src/users.ts"));
root.appendChild(ok);
await sleep(2800);
if (cancelled()) return;
// 8. Fade and loop.
root.classList.add("trun-fade");
await sleep(900);
if (cancelled()) return;
runCycle(root);
}
// Reduced-motion fallback — paint the final scene without typing.
function runStatic(root) {
root.innerHTML = "";
root.appendChild(buildHeader(1));
root.appendChild(buildUserRow(tr("term.user", "")));
root.appendChild(
buildToolRow(
"search_files",
"matched 2 files: src/users.ts, src/users.test.ts",
"0.4s",
"1"
)
);
root.appendChild(
buildToolRow("read_file", "read src/users.ts (412 chars)", "0.2s", "2")
);
const a = buildAssistantRow();
root.appendChild(a.row);
const p = el("p", "tmd-p");
p.textContent = tr("term.found", "");
a.body.appendChild(p);
const eb = buildEditBlock(
"src/users.ts",
[" return users.find(u => u.email === email);"],
[
" const needle = email.toLowerCase();",
" return users.find(u => u.email.toLowerCase() === needle);",
]
);
a.body.appendChild(eb.box);
eb.oldLines.forEach((l) =>
eb.diff.appendChild(el("div", "tdiff tdiff-old", "- " + l))
);
eb.newLines.forEach((l) =>
eb.diff.appendChild(el("div", "tdiff tdiff-new", "+ " + l))
);
root.appendChild(buildInfoRow("▸", "#94a3b8", tr("term.pending", "")));
}
function init() {
const root = document.querySelector(ROOT_SEL);
if (!root) return;
if (window.Reasonix && typeof window.Reasonix.onLangChange === "function") {
window.Reasonix.onLangChange(() => runCycle(root));
}
document.querySelectorAll(REPLAY_SEL).forEach((btn) => {
btn.addEventListener("click", () => runCycle(root));
});
const prm = window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
if (prm && prm.matches) {
runStatic(root);
return;
}
runCycle(root);
}
window.Reasonix = window.Reasonix || {};
window.Reasonix.term = {
replay() {
const root = document.querySelector(ROOT_SEL);
if (root) runCycle(root);
},
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.10.0",
"version": "0.11.3",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+18 -3
View File
@@ -9,6 +9,7 @@ import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
import {
loadSessionMessages,
rewriteSession,
@@ -53,7 +54,17 @@ 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; jobs?: import("../../tools/jobs.js").JobRegistry };
codeMode?: {
rootDir: string;
jobs?: import("../../tools/jobs.js").JobRegistry;
/**
* `/cwd <path>` callback — re-registers every rootDir-dependent
* native tool against the new path. Optional so embedders that
* don't want live cwd switching can omit it (the slash command
* then falls back to non-tool updates only).
*/
reregisterTools?: (rootDir: string) => void;
};
/** 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). */
@@ -182,7 +193,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const mcp = new McpClient({ transport });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, {
@@ -212,7 +225,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
}
const label = spec.name ?? "anon";
const source =
spec.transport === "sse" ? spec.url : `${spec.command} ${spec.args.join(" ")}`;
spec.transport === "sse" || spec.transport === "streamable-http"
? spec.url
: `${spec.command} ${spec.args.join(" ")}`;
process.stderr.write(
`▸ MCP[${label}]: ${bridge.registeredNames.length} tool(s) from ${source}\n`,
);
+40 -22
View File
@@ -20,6 +20,7 @@
import { basename, resolve } from "node:path";
import { loadEditMode, loadProjectShellAllowed } from "../../config.js";
import { bootstrapSemanticSearchInCodeMode } from "../../index/semantic/tool.js";
import { sanitizeName } from "../../session.js";
import { ToolRegistry } from "../../tools.js";
import { registerChoiceTool } from "../../tools/choice.js";
@@ -67,26 +68,39 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// shape instead of the `string="false"` JSON-in-string array that
// 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
// choices; merged on top of the built-in allowlist in shell.ts.
// GETTER form — re-read every dispatch so a prefix the user adds
// via ShellConfirm mid-session takes effect on the next shell call
// instead of waiting for `/new` or a relaunch.
extraAllowed: () => loadProjectShellAllowed(rootDir),
// `yolo` edit-mode disables shell confirmations entirely. Re-read
// from config on each dispatch so /mode yolo (or Shift+Tab cycling
// through to it) flips the gate live without forcing a relaunch.
allowAll: () => loadEditMode() === "yolo",
jobs,
});
// Bundled re-registration so `/cwd <path>` can swap every rootDir-
// dependent tool atomically. ToolRegistry.register is keyed by name
// and overwrites in-place, so re-calling these against the existing
// registry replaces the closures cleanly without disturbing tool
// specs (names/descriptions/params don't reference rootDir, so the
// prefix cache survives).
const registerRootedTools = (root: string): void => {
registerFilesystemTools(tools, { rootDir: root });
registerShellTools(tools, {
rootDir: root,
// Per-project "always allow" list persisted from prior ShellConfirm
// choices; merged on top of the built-in allowlist in shell.ts.
// GETTER form — re-read every dispatch so a prefix the user adds
// via ShellConfirm mid-session takes effect on the next shell call
// instead of waiting for `/new` or a relaunch.
extraAllowed: () => loadProjectShellAllowed(root),
// `yolo` edit-mode disables shell confirmations entirely. Re-read
// from config on each dispatch so /mode yolo (or Shift+Tab cycling
// through to it) flips the gate live without forcing a relaunch.
allowAll: () => loadEditMode() === "yolo",
jobs,
});
// `remember` / `forget` / `recall_memory` — cross-session user memory.
// Project scope hashes off rootDir so switching projects gets a fresh
// per-project memory store; the global scope is shared across runs.
registerMemoryTools(tools, { projectRoot: root });
};
registerRootedTools(rootDir);
// `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
// no-op outside plan mode and throws `PlanProposedError` when the
@@ -98,18 +112,22 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// menu into a submit_plan body. Keeping it always-registered
// preserves the prefix cache across plan-mode toggles.
registerChoiceTool(tools);
// `remember` / `forget` / `recall_memory` — cross-session user memory.
// Project scope hashes off rootDir so switching projects gets a fresh
// per-project memory store; the global scope is shared across runs.
registerMemoryTools(tools, { projectRoot: rootDir });
// `run_skill` is intentionally NOT registered here — App.tsx wires it
// up with the subagent runner attached, so `runAs: subagent` skills
// can spawn isolated child loops. Doing it here would mean the App's
// re-registration would shadow the no-runner version, which works
// (last write wins) but obscures the wiring.
// Bootstrap semantic_search. Silent: registers the tool when an
// on-disk index already exists, skips entirely otherwise. Setup
// happens via the explicit `reasonix index` command — never
// by surprise on launch.
const semantic = await bootstrapSemanticSearchInCodeMode(tools, rootDir);
process.stderr.write(
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" · ${tools.size} native tool(s)\n`,
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" · ${tools.size} native tool(s)${
semantic.enabled ? " · semantic_search on" : ""
}\n`,
);
// Belt-and-suspenders cleanup: even though spawn(detached:false)
@@ -128,11 +146,11 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
await chatCommand({
model: opts.model ?? "deepseek-v4-flash",
harvest: opts.harvest ?? false,
system: codeSystemPrompt(rootDir),
system: codeSystemPrompt(rootDir, { hasSemanticSearch: semantic.enabled }),
transcript: opts.transcript,
session,
seedTools: tools,
codeMode: { rootDir, jobs },
codeMode: { rootDir, jobs, reregisterTools: registerRootedTools },
forceResume: opts.forceResume,
forceNew: opts.forceNew,
});
+202
View File
@@ -0,0 +1,202 @@
/**
* `reasonix index` — build (or incrementally refresh) the project's
* semantic search index.
*
* Usage:
* reasonix index # incremental: only re-embeds changed files
* reasonix index --rebuild # wipe + rebuild from scratch
* reasonix index --model MODEL # override embedding model
* reasonix index --dir PATH # index a different directory
*
* Output is rendered to stderr so piping the index to a script
* (which we don't actually do today, but might) keeps stdout clean.
*
* On a TTY the progress writer paints `<spinner> <status> <elapsed>s`
* on a single line, ticking every 120ms via setInterval — so the
* line keeps animating even between onProgress events. That's the
* load-bearing UX guarantee: builds that take 30+ seconds never
* leave the user wondering whether the process is hung.
*
* On non-TTY (CI, piped logs) we emit one line per phase + a
* periodic heartbeat. No \r tricks because they make logs unreadable.
*/
import { resolve } from "node:path";
import { buildIndex } from "../../index/semantic/builder.js";
import type { BuildProgress, BuildResult } from "../../index/semantic/builder.js";
import { t } from "../../index/semantic/i18n.js";
import { ollamaPreflight } from "../../index/semantic/preflight.js";
export interface IndexCommandOptions {
rebuild?: boolean;
model?: string;
dir?: string;
/** Ollama base URL override. */
ollamaUrl?: string;
/** Skip preflight prompts (yes to all). For scripts that already
* know Ollama is set up. Default false. */
yes?: boolean;
}
export async function indexCommand(opts: IndexCommandOptions = {}): Promise<void> {
const root = resolve(opts.dir ?? process.cwd());
const tty = process.stderr.isTTY === true && process.stdin.isTTY === true;
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text";
// Preflight: detect Ollama state and offer to fix what's missing.
// Runs before chunking so the user doesn't watch a scan finish only
// to hit "daemon not reachable" on the first embed call.
const preflightOk = await ollamaPreflight({
model,
baseUrl: opts.ollamaUrl,
interactive: tty && !opts.yes,
yesToAll: opts.yes ?? false,
});
if (!preflightOk) process.exit(1);
const writer = makeProgressWriter(tty);
const t0 = Date.now();
let result: BuildResult;
try {
result = await buildIndex(root, {
rebuild: opts.rebuild,
model,
baseUrl: opts.ollamaUrl,
onProgress: (p) => writer.update(p),
});
} catch (err) {
writer.clear();
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(t("indexFailed", { msg }));
process.exit(1);
}
writer.clear();
const seconds = ((Date.now() - t0) / 1000).toFixed(1);
const successKey = result.chunksSkipped > 0 ? "indexSuccessWithSkips" : "indexSuccess";
process.stderr.write(
t(successKey, {
scanned: result.filesScanned,
changed: result.filesChanged,
added: result.chunksAdded,
removed: result.chunksRemoved,
skipped: result.chunksSkipped,
seconds,
}),
);
if (result.filesChanged === 0 && !opts.rebuild) {
process.stderr.write(t("indexNothingToDo"));
}
}
interface ProgressWriter {
update(p: BuildProgress): void;
clear(): void;
}
/**
* Braille spinner — same alphabet most CLI spinners use. Ten frames,
* cycled at ~120ms each. Visually clear that the process is alive
* even when no progress events fire (e.g. during phase transitions
* or while waiting on Ollama's first model-load latency).
*/
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_INTERVAL_MS = 120;
function makeProgressWriter(tty: boolean): ProgressWriter {
if (!tty) return makeNonTtyWriter();
return makeTtyWriter();
}
/**
* Non-TTY writer — one line per phase transition + a heartbeat every
* 50 embedded chunks. Keeps logs short and parseable while still
* proving forward motion.
*/
function makeNonTtyWriter(): ProgressWriter {
let lastPhase: BuildProgress["phase"] | null = null;
let lastChunks = 0;
return {
update(p) {
if (p.phase !== lastPhase) {
lastPhase = p.phase;
if (p.phase === "scan") {
process.stderr.write(t("progressScanLine"));
} else if (p.phase === "embed") {
process.stderr.write(
t("progressEmbedLine", {
total: p.chunksTotal ?? 0,
files: p.filesChanged ?? 0,
}),
);
}
}
if (p.phase === "embed" && p.chunksDone !== undefined && p.chunksDone - lastChunks >= 50) {
lastChunks = p.chunksDone;
process.stderr.write(
t("progressEmbedHeartbeat", {
done: p.chunksDone,
total: p.chunksTotal ?? "?",
}),
);
}
},
clear() {
/* non-TTY keeps its accumulated lines */
},
};
}
/**
* TTY writer — paints `<spinner> <status> <elapsed>s` on a single
* line via \r. The spinner ticks on a setInterval, INDEPENDENT of
* onProgress events: even if the embedder hangs for 5 seconds on
* the first model-load, the spinner keeps spinning so the user sees
* the process is alive.
*/
function makeTtyWriter(): ProgressWriter {
let status = t("progressStarting");
let lastLineLen = 0;
let frameIdx = 0;
const startTs = Date.now();
const repaint = () => {
const frame = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
frameIdx++;
const elapsed = ((Date.now() - startTs) / 1000).toFixed(1);
const line = `${frame} ${status} ${elapsed}s`;
const padded = line + " ".repeat(Math.max(0, lastLineLen - line.length));
process.stderr.write(`\r${padded}`);
lastLineLen = line.length;
};
// Paint the initial frame immediately so the user sees text on
// line one — don't wait for the first interval tick.
repaint();
const interval = setInterval(repaint, SPINNER_INTERVAL_MS);
return {
update(p) {
if (p.phase === "scan") {
status = t("progressScan", { files: p.filesScanned ?? 0 });
} else if (p.phase === "embed") {
const done = p.chunksDone ?? 0;
const total = p.chunksTotal ?? 0;
const pct = total > 0 ? ((done / total) * 100).toFixed(0) : "0";
status = t("progressEmbed", { done, total, pct });
}
// Repaint immediately on event arrival so the new counter
// shows up before the next interval tick (avoids a 120ms lag
// on each progress update).
repaint();
},
clear() {
clearInterval(interval);
if (lastLineLen > 0) {
process.stderr.write(`\r${" ".repeat(lastLineLen)}\r`);
lastLineLen = 0;
}
},
};
}
+4 -1
View File
@@ -15,6 +15,7 @@ import type { InspectionReport } from "../../mcp/inspect.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
export interface McpInspectOptions {
/** The raw --mcp spec string (e.g. `fs=npx -y @modelcontextprotocol/server-filesystem .`). */
@@ -28,7 +29,9 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const client = new McpClient({ transport });
try {
await client.initialize();
+7 -2
View File
@@ -9,6 +9,7 @@ import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
import { ToolRegistry } from "../../tools.js";
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
import { appendUsage } from "../../usage.js";
@@ -86,12 +87,16 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const mcp = new McpClient({ transport });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix });
const source =
spec.transport === "sse" ? spec.url : `${spec.command} ${spec.args.join(" ")}`;
spec.transport === "sse" || spec.transport === "streamable-http"
? spec.url
: `${spec.command} ${spec.args.join(" ")}`;
process.stderr.write(
`▸ MCP[${spec.name ?? "anon"}]: ${bridge.registeredNames.length} tool(s) from ${source}\n`,
);
+26
View File
@@ -7,6 +7,7 @@ import { applyMemoryStack } from "../user-memory.js";
import { chatCommand } from "./commands/chat.js";
import { codeCommand } from "./commands/code.js";
import { diffCommand } from "./commands/diff.js";
import { indexCommand } from "./commands/index.js";
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
import { mcpListCommand } from "./commands/mcp.js";
import { replayCommand } from "./commands/replay.js";
@@ -327,6 +328,31 @@ program
await updateCommand({ dryRun: !!opts.dryRun });
});
program
.command("index")
.description(
"Build (or incrementally refresh) a local semantic search index for the project so `reasonix code` can answer 'where do we…' questions by meaning, not just by token. Uses Ollama as the embedding backend; missing daemon / model is offered to the user with a confirm prompt.",
)
.option("--rebuild", "Wipe and rebuild from scratch")
.option("--model <name>", "Embedding model (default: nomic-embed-text)")
.option("--dir <path>", "Project root to index (default: cwd)")
.option("--ollama-url <url>", "Override Ollama base URL (default: http://localhost:11434)")
.option(
"-y, --yes",
"Skip preflight prompts — auto-start the daemon and pull the model if missing (use in scripts)",
)
.action(
async (opts: {
rebuild?: boolean;
model?: string;
dir?: string;
ollamaUrl?: string;
yes?: boolean;
}) => {
await indexCommand(opts);
},
);
program.parseAsync(process.argv).catch((err) => {
console.error(err);
process.exit(1);
+253 -42
View File
@@ -1,4 +1,5 @@
import type { WriteStream } from "node:fs";
import * as pathMod from "node:path";
import { Box, Static, Text, useApp, useStdout } from "ink";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type AtUrlExpansion, expandAtMentions, expandAtUrls } from "../../at-mentions.js";
@@ -40,6 +41,7 @@ import { formatCommandResult, runCommand } from "../../tools/shell.js";
import { registerSkillTools } from "../../tools/skills.js";
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
import { webFetch } from "../../tools/web.js";
import { registerWorkspaceTool } from "../../tools/workspace.js";
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
import { appendUsage } from "../../usage.js";
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
@@ -57,6 +59,7 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatsPanel } from "./StatsPanel.js";
import { WelcomeBanner } from "./WelcomeBanner.js";
import { WorkspaceConfirm, type WorkspaceConfirmChoice } from "./WorkspaceConfirm.js";
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
import {
describeRepair,
@@ -114,7 +117,17 @@ export interface AppProps {
* optional `jobs` registry enables /jobs + /kill slashes in the TUI
* and the status-bar "N jobs running" indicator.
*/
codeMode?: { rootDir: string; jobs?: import("../../tools/jobs.js").JobRegistry };
codeMode?: {
rootDir: string;
jobs?: import("../../tools/jobs.js").JobRegistry;
/**
* `/cwd <path>` callback — re-registers every rootDir-dependent
* native tool against the new path. Optional: when omitted the
* slash command degrades to updating hook cwd / memory root only,
* with file/shell tools still pointing at the original root.
*/
reregisterTools?: (rootDir: string) => void;
};
}
/**
@@ -281,17 +294,23 @@ export function App({
// summary). Rendered as a dim spinner row; auto-cleared on the next
// primary event.
const [statusLine, setStatusLine] = useState<string | null>(null);
// Live working directory for every rootDir-dependent surface:
// hook cwd, memory root, project shell allowlist root, `@file`
// mention root, `applyEditBlocks` base, run_command cwd, project-
// settings hook loader. `/cwd <path>` mutates this state to swap
// the workspace mid-session; the prop `codeMode.rootDir` stays as
// the original launch root so it can't accidentally drift (it's
// used purely for "is this a code-mode session?" checks now).
const [currentRootDir, setCurrentRootDir] = useState<string>(
() => codeMode?.rootDir ?? process.cwd(),
);
// Loaded user hooks (project + global settings.json). Stays mutable
// so `/hooks reload` can rescan disk without reconstructing the
// loop. The loop holds a parallel reference for its tool-event
// dispatch; we keep them in sync via the effect below.
// so `/hooks reload` and `/cwd` can rescan disk without
// reconstructing the loop. The loop holds a parallel reference for
// its tool-event dispatch; we keep them in sync via the effect below.
const [hookList, setHookList] = useState<ResolvedHook[]>(() =>
loadHooks({ projectRoot: codeMode?.rootDir }),
);
// Working directory reported in every hook's stdin payload. Hook
// scripts that `cd $REASONIX_CWD` (or read `cwd` from the JSON
// envelope) land in the project root, not the user's shell home.
const hookCwd = codeMode?.rootDir ?? process.cwd();
// Session-scoped edit history + undo banner + /undo, /history, /show
// handlers. Kept in a custom hook so App.tsx only sees the small API
// it needs — append an edit, arm the banner, answer the slash
@@ -389,6 +408,13 @@ export function App({
*/
kind: "run_command" | "run_background";
} | null>(null);
// The latest `change_workspace` request awaiting user approval.
// Populated from the WorkspaceConfirmationError surfaced in the
// tool result; cleared once the user picks Switch / Deny in
// WorkspaceConfirm. Mutually exclusive with pendingShell — only
// one modal can be open at a time, and we gate input + render on
// both flags wherever pendingShell is gated.
const [pendingWorkspace, setPendingWorkspace] = useState<{ path: string } | null>(null);
// Plan text the model submitted via `submit_plan` while plan mode
// was active. Non-null renders PlanConfirm; user picks Approve /
// Refine / Cancel and we drive the loop from there. Separate from
@@ -592,13 +618,14 @@ export function App({
}, []);
const loopRef = useRef<CacheFirstLoop | null>(null);
// hookList + hookCwd intentionally NOT in deps — they seed the loop
// on first construction (loopRef guards a single instantiation), and
// later edits flow in through the mutable `loop.hooks = hookList`
// effect below. Putting them in deps would tear down the loop on
// every reload, wiping the append-only log mid-session.
// hookList + currentRootDir intentionally NOT in deps — they seed
// the loop on first construction (loopRef guards a single
// instantiation), and later edits flow in through the mutable
// `loop.hooks = hookList` / `loop.hookCwd = currentRootDir` effects
// below. Putting them in deps would tear down the loop on every
// reload, wiping the append-only log mid-session.
// biome-ignore lint/correctness/useExhaustiveDependencies: hookList — see comment above
// biome-ignore lint/correctness/useExhaustiveDependencies: hookCwd — see comment above
// biome-ignore lint/correctness/useExhaustiveDependencies: currentRootDir — see comment above
const loop = useMemo(() => {
if (loopRef.current) return loopRef.current;
const client = new DeepSeekClient();
@@ -634,6 +661,16 @@ export function App({
},
});
}
// `change_workspace` — model-callable workspace switching, gated
// on a confirmation modal driven by App.tsx (see pendingWorkspace
// state below). Tool fn validates the path and throws
// WorkspaceConfirmationError; the actual swap happens when the
// user approves. Registered here (not in code.tsx) so chat-mode
// sessions also expose it — `setCwd` works in chat mode too,
// just doesn't have a tool sandbox to re-register.
if (tools && !tools.has("change_workspace")) {
registerWorkspaceTool(tools);
}
const prefix = new ImmutablePrefix({
system,
toolSpecs: tools?.specs(),
@@ -647,7 +684,7 @@ export function App({
branch,
session,
hooks: hookList,
hookCwd,
hookCwd: currentRootDir,
// Restore the user's last-chosen effort cap. Without this a
// `/effort high` silently reverted to `max` on relaunch — the
// loop's constructor default wins over persisted state.
@@ -664,6 +701,77 @@ export function App({
loop.hooks = hookList;
}, [loop, hookList]);
// Shared cwd-switch implementation — drives BOTH the `/cwd` slash
// command and the `change_workspace` tool's confirmation handler.
// Returns a multi-line info string the caller can surface to the
// user (slash) or fold into a synthetic message (tool). Idempotent
// on the same path.
const applyCwdChange = useCallback(
(newRoot: string): string => {
// Update the React state — every memoized derivation that reads
// `currentRootDir` (memoryRoot, hookCwd via useEffect,
// applyEditBlocks paths, mention root, run_command cwd) picks
// up the new path on the next render.
setCurrentRootDir(newRoot);
// Reload hooks against the new project (different
// .reasonix/settings.json may exist there).
const fresh = loadHooks({ projectRoot: codeMode ? newRoot : undefined });
setHookList(fresh);
// Re-register every rootDir-dependent native tool so file
// reads/writes and shell calls land in the new sandbox. Only
// available in code mode (chat mode has no rootDir-bound tools
// beyond memory, which we re-register inline below).
const codeRebound = codeMode?.reregisterTools !== undefined;
if (codeMode?.reregisterTools) {
codeMode.reregisterTools(newRoot);
}
// Keep `run_skill`'s closured projectRoot in sync too. It's
// owned by App.tsx (the subagent runner needs in-process refs),
// so the codeMode reregister hook above doesn't touch it. Safe
// to re-register: the registry overwrites by tool name, the
// spec is identical, prefix cache stays.
if (tools) {
registerSkillTools(tools, {
projectRoot: codeMode ? newRoot : undefined,
subagentRunner: async (skill, task) => {
const result = await spawnSubagent({
client: loop.client,
parentRegistry: tools,
system: skill.body,
task,
model: skill.model,
sink: subagentSinkRef.current,
skillName: skill.name,
});
return formatSubagentResult(result);
},
});
}
const lines = [`▸ cwd → ${newRoot}`, ` hooks reloaded (${fresh.length} active)`];
if (codeMode) {
lines.push(
codeRebound
? " filesystem / shell / memory tools rebound to new root"
: " warning: reregisterTools callback missing — tool sandbox unchanged",
);
lines.push(
" note: system prompt context (gitignore, REASONIX.md stack) was",
" baked at session start and still references the original root.",
);
}
return lines.join("\n");
},
[codeMode, loop, tools, subagentSinkRef],
);
// Mirror `currentRootDir` into the loop's mutable `hookCwd` so
// `/cwd` switches the path threaded into every hook's stdin
// envelope without reconstructing the loop. Same shape as the
// hookList sync above — the loop holds a parallel mutable copy.
useEffect(() => {
loop.hookCwd = currentRootDir;
}, [loop, currentRootDir]);
// Ambient session info (balance, model catalog, latest published
// version) — three independent mount-time fetches behind one hook
// so the refresh callbacks can be wired into handleSubmit's finally
@@ -699,7 +807,14 @@ export function App({
slashArgSelected,
setSlashArgSelected,
pickSlashArg,
} = useCompletionPickers({ input, setInput, codeMode, models, mcpServers });
} = useCompletionPickers({
input,
setInput,
codeMode,
rootDir: currentRootDir,
models,
mcpServers,
});
// Wire the shared progressSink so the bridge's onProgress → us.
// Only updates progress when the frame belongs to the currently-
@@ -927,6 +1042,7 @@ export function App({
key.shift &&
key.tab &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -966,6 +1082,7 @@ export function App({
input.length === 0 &&
(chKey === "u" || chKey === "U") &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -1122,7 +1239,7 @@ export function App({
// the queued block is a literal whole-file overwrite. For new
// files SEARCH stays empty — applyEditBlock's create-new sentinel.
const content = typeof args.content === "string" ? args.content : "";
block = toWholeFileEditBlock(relPath, content, codeMode.rootDir);
block = toWholeFileEditBlock(relPath, content, currentRootDir);
}
// Helper: apply the current block + record into history + arm
@@ -1138,8 +1255,8 @@ export function App({
// the "result shown twice" bug reported in 0.6 (one dim info
// row, then a nearly identical tool row directly below).
const applyNow = (): string => {
const snaps = snapshotBeforeEdits([block], codeMode.rootDir);
const results = applyEditBlocks([block], codeMode.rootDir);
const snaps = snapshotBeforeEdits([block], currentRootDir);
const results = applyEditBlocks([block], currentRootDir);
const good = results.some((r) => r.status === "applied" || r.status === "created");
if (good) {
recordEdit("auto", [block], results, snaps);
@@ -1233,8 +1350,8 @@ export function App({
if (selected.length === 0) {
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
}
const snaps = snapshotBeforeEdits(selected, codeMode.rootDir);
const results = applyEditBlocks(selected, codeMode.rootDir);
const snaps = snapshotBeforeEdits(selected, currentRootDir);
const results = applyEditBlocks(selected, currentRootDir);
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
pendingEdits.current = remaining;
@@ -1247,7 +1364,7 @@ export function App({
: "";
return formatEditResults(results) + tail;
},
[codeMode, session, syncPendingCount, recordEdit],
[codeMode, currentRootDir, session, syncPendingCount, recordEdit],
);
/**
@@ -1514,7 +1631,7 @@ export function App({
const hashParse = detectHashMemory(text);
if (hashParse?.kind === "memory" || hashParse?.kind === "memory-global") {
const isGlobal = hashParse.kind === "memory-global";
const memRoot = codeMode?.rootDir ?? process.cwd();
const memRoot = currentRootDir;
promptHistory.current.push(text);
try {
const result = isGlobal
@@ -1557,7 +1674,7 @@ export function App({
// happened AND the bang exchange survives session resume.
const bangCmd = detectBangCommand(text);
if (bangCmd !== null) {
const bangRoot = codeMode?.rootDir ?? process.cwd();
const bangRoot = currentRootDir;
promptHistory.current.push(text);
setHistorical((prev) => [
...prev,
@@ -1627,10 +1744,10 @@ export function App({
codeDiscard: codeMode ? codeDiscard : undefined,
codeHistory: codeMode ? codeHistory : undefined,
codeShowEdit: codeMode ? codeShowEdit : undefined,
codeRoot: codeMode?.rootDir,
codeRoot: codeMode ? currentRootDir : undefined,
pendingEditCount: codeMode ? pendingEdits.current.length : undefined,
toolHistory: () => toolHistoryRef.current,
memoryRoot: codeMode?.rootDir ?? process.cwd(),
memoryRoot: currentRootDir,
planMode,
setPlanMode: codeMode ? togglePlanMode : undefined,
clearPendingPlan: codeMode ? clearPendingPlan : undefined,
@@ -1655,10 +1772,11 @@ export function App({
{ id: `sys-late-${Date.now()}-${Math.random()}`, role: "info", text },
]),
reloadHooks: () => {
const fresh = loadHooks({ projectRoot: codeMode?.rootDir });
const fresh = loadHooks({ projectRoot: codeMode ? currentRootDir : undefined });
setHookList(fresh);
return fresh.length;
},
setCwd: (newRoot: string) => applyCwdChange(newRoot),
latestVersion,
refreshLatestVersion,
models,
@@ -1764,7 +1882,7 @@ export function App({
if (hookList.some((h) => h.event === "UserPromptSubmit")) {
const promptReport = await runHooks({
hooks: hookList,
payload: { event: "UserPromptSubmit", cwd: hookCwd, prompt: text },
payload: { event: "UserPromptSubmit", cwd: currentRootDir, prompt: text },
});
if (promptReport.outcomes.length > 0) {
setHistorical((prev) => [
@@ -1870,8 +1988,8 @@ export function App({
// block; the Historical row above keeps the user's verbatim text
// so the display doesn't balloon.
let modelInput = text;
if (codeMode?.rootDir) {
const expanded = expandAtMentions(text, codeMode.rootDir);
if (codeMode) {
const expanded = expandAtMentions(text, currentRootDir);
if (expanded.expansions.length > 0) {
modelInput = expanded.text;
const inlined = expanded.expansions
@@ -2046,8 +2164,8 @@ export function App({
const blocks = parseEditBlocks(finalText);
if (blocks.length > 0) {
if (editModeRef.current === "auto" || editModeRef.current === "yolo") {
const snaps = snapshotBeforeEdits(blocks, codeMode.rootDir);
const results = applyEditBlocks(blocks, codeMode.rootDir);
const snaps = snapshotBeforeEdits(blocks, currentRootDir);
const results = applyEditBlocks(blocks, currentRootDir);
const good = results.some(
(r) => r.status === "applied" || r.status === "created",
);
@@ -2167,6 +2285,34 @@ export function App({
/* malformed args — skip the prompt */
}
}
// change_workspace surfaced its WorkspaceConfirmationError —
// the resolved absolute path is on the error message between
// the `"` markers (`switching to "/abs/path" needs ...`). We
// re-derive it from the args rather than parsing the message
// so a future error-text rewording doesn't break the modal.
if (
ev.toolName === "change_workspace" &&
ev.content.includes('"WorkspaceConfirmationError:') &&
ev.toolArgs
) {
try {
const parsed = JSON.parse(ev.toolArgs) as { path?: unknown };
if (typeof parsed.path === "string" && parsed.path.trim()) {
// Re-resolve the same way the tool fn did so the modal
// shows the canonical destination even when the model
// passed a relative or `~`-prefixed path.
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded =
parsed.path.startsWith("~") && home
? pathMod.join(home, parsed.path.slice(1))
: parsed.path;
const abs = pathMod.resolve(expanded);
setPendingWorkspace({ path: abs });
}
} catch {
/* malformed args — skip the prompt */
}
}
// submit_plan fired while plan mode was on — the registry
// serialized `{ error, plan, steps? }` via PlanProposedError's
// toToolResult(). Extract the plan and mount PlanConfirm.
@@ -2385,7 +2531,7 @@ export function App({
hooks: hookList,
payload: {
event: "Stop",
cwd: hookCwd,
cwd: currentRootDir,
lastAssistantText: streamRef.text,
turn: loop.stats.summary().turns,
},
@@ -2426,8 +2572,8 @@ export function App({
codeMode,
codeShowEdit,
codeUndo,
currentRootDir,
exit,
hookCwd,
hookList,
loop,
latestVersion,
@@ -2463,6 +2609,7 @@ export function App({
startLoop,
getLoopStatus,
startWalkthrough,
applyCwdChange,
],
);
@@ -2550,13 +2697,13 @@ export function App({
} else {
if (choice === "always_allow") {
const prefix = derivePrefix(cmd);
addProjectShellAllowed(codeMode.rootDir, prefix);
addProjectShellAllowed(currentRootDir, prefix);
setHistorical((prev) => [
...prev,
{
id: `sh-allow-${Date.now()}`,
role: "info",
text: `▸ always allowed "${prefix}" for ${codeMode.rootDir}`,
text: `▸ always allowed "${prefix}" for ${currentRootDir}`,
},
]);
}
@@ -2577,7 +2724,7 @@ export function App({
let jobId: number | null = null;
let preview = "";
try {
const res = await codeMode.jobs.start(cmd, { cwd: codeMode.rootDir });
const res = await codeMode.jobs.start(cmd, { cwd: currentRootDir });
startedOk = true;
jobId = res.jobId;
preview = res.preview;
@@ -2606,7 +2753,7 @@ export function App({
// Foreground (run_command) — synchronous; waits for exit.
let body: string;
try {
const res = await runCommand(cmd, { cwd: codeMode.rootDir });
const res = await runCommand(cmd, { cwd: currentRootDir });
body = formatCommandResult(cmd, res);
} catch (err) {
body = `$ ${cmd}\n[failed to spawn] ${(err as Error).message}`;
@@ -2630,7 +2777,7 @@ export function App({
await handleSubmit(synthetic);
}
},
[pendingShell, codeMode, handleSubmit, busy, loop],
[pendingShell, codeMode, currentRootDir, handleSubmit, busy, loop],
);
// Drain the shell-confirm queue after the in-flight turn tears down.
@@ -2644,6 +2791,56 @@ export function App({
}
}, [busy, queuedSubmit, handleSubmit]);
/**
* WorkspaceConfirm callback. Two outcomes, both ending with a
* synthetic user message so the model sees what happened on its
* next turn:
* - deny → tell the model the user refused, continue without it.
* - switch → call applyCwdChange (same path as `/cwd`), surface
* the cwd-change info row to scrollback, hand the model a
* short confirmation so it can resume the user's request
* against the new sandbox.
*/
const handleWorkspaceConfirm = useCallback(
async (choice: WorkspaceConfirmChoice) => {
const pending = pendingWorkspace;
if (!pending) return;
const target = pending.path;
setPendingWorkspace(null);
let synthetic: string;
if (choice === "deny") {
setHistorical((prev) => [
...prev,
{
id: `ws-deny-${Date.now()}`,
role: "info",
text: `▸ denied workspace switch: ${target}`,
},
]);
synthetic = `I denied switching the workspace to \`${target}\`. Please continue without changing directories.`;
} else {
const info = applyCwdChange(target);
setHistorical((prev) => [
...prev,
{ id: `ws-switch-${Date.now()}`, role: "info", text: info },
]);
synthetic = `I approved the workspace switch. The session is now rooted at \`${target}\` — your filesystem / shell / memory tools resolve against that path on every subsequent call. Continue with my original request from this new root.`;
}
// Same race protection as handleShellConfirm: if the prior
// turn is still streaming, abort it and queue the synthetic
// for the busy=false edge detector below.
if (busy) {
loop.abort();
setQueuedSubmit(synthetic);
} else {
await handleSubmit(synthetic);
}
},
[pendingWorkspace, applyCwdChange, busy, loop, handleSubmit],
);
/**
* PlanConfirm callback. Three outcomes, all ending with a synthetic
* user message so the model sees the verdict on its next turn:
@@ -3045,6 +3242,7 @@ export function App({
isResizing ||
!!pendingPlan ||
!!pendingShell ||
!!pendingWorkspace ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingCheckpoint ||
@@ -3071,7 +3269,7 @@ export function App({
escalated={turnOnPro}
/>
<Static items={historical}>
{(item) => <EventRow key={item.id} event={item} projectRoot={hookCwd} />}
{(item) => <EventRow key={item.id} event={item} projectRoot={currentRootDir} />}
</Static>
{/*
Welcome card on the empty state. Visible only when nothing
@@ -3093,6 +3291,7 @@ export function App({
*/}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3100,11 +3299,12 @@ export function App({
!stagedCheckpointRevise &&
streaming ? (
<Box marginY={1}>
<EventRow event={streaming} projectRoot={hookCwd} />
<EventRow event={streaming} projectRoot={currentRootDir} />
</Box>
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3115,6 +3315,7 @@ export function App({
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3125,6 +3326,7 @@ export function App({
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3137,6 +3339,7 @@ export function App({
{!PLAIN_UI &&
undoBanner &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3157,6 +3360,7 @@ export function App({
*/}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3219,7 +3423,7 @@ export function App({
steps={planStepsRef.current ?? undefined}
summary={planSummaryRef.current ?? undefined}
onChoose={stableHandlePlanConfirm}
projectRoot={hookCwd}
projectRoot={currentRootDir}
/>
) : pendingShell ? (
<ShellConfirm
@@ -3228,6 +3432,13 @@ export function App({
kind={pendingShell.kind}
onChoose={handleShellConfirm}
/>
) : pendingWorkspace ? (
<WorkspaceConfirm
path={pendingWorkspace.path}
currentRoot={currentRootDir}
mcpServerCount={mcpServers?.length ?? 0}
onChoose={handleWorkspaceConfirm}
/>
) : pendingEditReview ? (
<EditConfirm
block={pendingEditReview}
+69
View File
@@ -0,0 +1,69 @@
import { Box, Text } from "ink";
import React from "react";
import { ModalCard } from "./ModalCard.js";
import { SingleSelect } from "./Select.js";
export type WorkspaceConfirmChoice = "switch" | "deny";
export interface WorkspaceConfirmProps {
/** Resolved absolute path the model wants to switch to. */
path: string;
/** Current session root, shown above the target so the user sees the diff. */
currentRoot: string;
/** Number of MCP servers still attached — surfaced so the user knows
* those won't follow the switch (their child processes were spawned
* with the original cwd). 0 means no warning. */
mcpServerCount: number;
onChoose: (choice: WorkspaceConfirmChoice) => void;
}
/**
* Modal-style approval for a `change_workspace` tool call. Two
* choices, Enter / Esc bindings. No "always allow" — workspace
* switches are per-target by nature.
*/
export function WorkspaceConfirm({
path,
currentRoot,
mcpServerCount,
onChoose,
}: WorkspaceConfirmProps) {
const subtitle =
mcpServerCount > 0
? `MCP servers (${mcpServerCount}) stay anchored to the original launch root.`
: "Re-registers filesystem / shell / memory tools at the new path.";
return (
<ModalCard accent="#f59e0b" icon="⇄" title="switch workspace" subtitle={subtitle}>
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text dimColor>{"from "}</Text>
<Text color="#a3a3a3">{currentRoot}</Text>
</Box>
<Box>
<Text dimColor>{"to "}</Text>
<Text color="#67e8f9" bold>
{path}
</Text>
</Box>
</Box>
<SingleSelect
initialValue="switch"
items={[
{
value: "switch",
label: "Switch",
hint: "Re-register filesystem / shell / memory tools against the new root.",
},
{
value: "deny",
label: "Deny",
hint: "Tell the model the user refused; it will continue without changing directories.",
},
]}
onSubmit={(v) => onChoose(v as WorkspaceConfirmChoice)}
onCancel={() => onChoose("deny")}
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
/>
</ModalCard>
);
}
+32
View File
@@ -63,7 +63,39 @@ function toSubscript(s: string): string {
return out;
}
/**
* Math indicator regex — gates the LaTeX-stripping pipeline so plain
* prose with literal backslashes (Windows paths, escape sequences in
* code prose) doesn't get mauled by the catch-all `\[a-zA-Z]+ → ""`
* fallback at the bottom of stripMath.
*
* Without this guard, "F:\TEST1" got rendered as "F:1" because the
* catch-all interpreted `\TEST` as an unknown LaTeX command and
* deleted it. We only run the pipeline when the text actually
* contains math markers we recognize: $-delimited spans, `\(`/`\[`
* paren-delimiters, or one of the explicit LaTeX command names the
* pipeline knows how to translate. Anything else passes through
* untouched.
*/
const HAS_MATH_RE = new RegExp(
[
"\\$", // dollar-delimited (block or inline)
"\\\\[([]", // \( or \[
"\\\\[a-zA-Z]+\\s*\\{", // \anyCommand{...} — covers catch-all braced transforms
// Bare (no-brace) LaTeX commands the pipeline knows how to handle.
// Listed explicitly because a generic `\\[a-zA-Z]+` would also match
// Windows paths (`F:\TEST1`) and re-introduce the bug we're fixing.
"\\\\(?:cdot|times|div|pm|mp|leq|geq|neq|approx|in|notin|infty|sum|prod|int|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|phi|omega|implies|iff|to|rightarrow|leftarrow|Rightarrow|Leftarrow|ldots|cdots|quad|qquad)(?![a-zA-Z])",
"[\\^_]\\{", // LaTeX braced super/subscript: ^{2}, _{ij}
"\\^[0-9+\\-n](?![A-Za-z])", // LaTeX single-char super: ^2, ^-, ^n
"_[0-9+\\-](?![A-Za-z])", // LaTeX single-char sub: _1, _+, _-
"\\^[A-Za-z0-9+\\-]+\\^", // Pandoc super: ^2^, ^abc^
"(?<!~)~[A-Za-z0-9+\\-]+~(?!~)", // Pandoc sub: ~2~ (lookarounds avoid ~~strike~~)
].join("|"),
);
export function stripMath(s: string): string {
if (!HAS_MATH_RE.test(s)) return s;
return (
s
// Dollar-delimited math (KaTeX / MathJax convention). Block
+25
View File
@@ -69,6 +69,19 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[reload]",
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
},
{
cmd: "permissions",
argsHint: "[list|add <prefix>|remove <prefix|N>|clear confirm]",
summary:
"show / edit shell allowlist (builtin read-only · per-project: ~/.reasonix/config.json)",
argCompleter: ["list", "add", "remove", "clear"],
},
{
cmd: "cwd",
argsHint: "<path>",
summary:
"switch session working directory (re-registers code tools, reloads hooks; MCP servers stay)",
},
{
cmd: "update",
summary: "show current vs latest version + the shell command to upgrade",
@@ -100,6 +113,10 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ 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: "semantic",
summary: "show semantic_search status — built? Ollama installed? how to enable",
},
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
{
@@ -109,6 +126,14 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{ cmd: "exit", summary: "quit the TUI" },
// Code-mode only
{
cmd: "init",
argsHint: "[force]",
summary:
"scan the project and synthesize a baseline REASONIX.md (model writes; review with /apply). `force` overwrites an existing file.",
contextual: "code",
argCompleter: ["force"],
},
{
cmd: "apply",
argsHint: "[N|N,M|N-M]",
+6
View File
@@ -2,12 +2,15 @@ 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 initHandlers } from "./handlers/init.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 permissionsHandlers } from "./handlers/permissions.js";
import { handlers as plansHandlers } from "./handlers/plans.js";
import { handlers as semanticHandlers } from "./handlers/semantic.js";
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
import { handlers as skillHandlers } from "./handlers/skill.js";
import type { SlashContext, SlashResult } from "./types.js";
@@ -32,12 +35,15 @@ const HANDLERS: Record<string, SlashHandler> = {
...adminHandlers,
...basicHandlers,
...editsHandlers,
...initHandlers,
...jobsHandlers,
...mcpHandlers,
...memoryHandlers,
...modelHandlers,
...observabilityHandlers,
...permissionsHandlers,
...plansHandlers,
...semanticHandlers,
...sessionsHandlers,
...skillHandlers,
};
+62
View File
@@ -1,3 +1,5 @@
import { existsSync, statSync } from "node:fs";
import * as pathMod from "node:path";
import {
HOOK_EVENTS,
type HookEvent,
@@ -150,9 +152,69 @@ const stats: SlashHandler = () => {
return { info: renderDashboard(agg, path) };
};
/**
* `/cwd <path>` — switch the session working directory mid-session.
* Validates the target (must exist, must be a directory), normalizes
* to an absolute path with `~` expansion, then defers the actual swap
* to the App-supplied `setCwd` callback. The callback updates hook
* cwd, memory root, project shell allowlist root, `@file` mention
* root, and (in code mode) re-registers filesystem / shell / memory /
* skill tools — see App.tsx for the wiring.
*
* MCP servers do NOT follow the cwd switch — their stdio child was
* spawned with the original cwd at session start, and there's no
* standard "reconnect with new cwd" handshake. The handler surfaces
* a one-line warning when MCP servers are present so users aren't
* surprised by tools still resolving paths against the old root.
*/
const cwd: SlashHandler = (args, _loop, ctx) => {
if (!ctx.setCwd) {
return {
info: "/cwd is not available in this context (no setCwd callback wired).",
};
}
const raw = (args[0] ?? "").trim();
if (!raw) {
return {
info: "usage: /cwd <path> (absolute or relative, ~ expands to home)",
};
}
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded = raw.startsWith("~") && home ? pathMod.join(home, raw.slice(1)) : raw;
const abs = pathMod.resolve(expanded);
if (!existsSync(abs)) {
return { info: `▸ /cwd: path does not exist — ${abs}` };
}
let isDir = false;
try {
isDir = statSync(abs).isDirectory();
} catch {
// Permission denied or transient FS error — treat as not-a-dir
// and let the user see the exact path so they can investigate.
}
if (!isDir) {
return { info: `▸ /cwd: not a directory — ${abs}` };
}
let info: string;
try {
info = ctx.setCwd(abs);
} catch (err) {
return { info: `▸ /cwd failed: ${(err as Error).message}` };
}
const lines = [info];
if (ctx.mcpServers && ctx.mcpServers.length > 0) {
lines.push(
` note: ${ctx.mcpServers.length} MCP server(s) still anchored to the original cwd —`,
" their tools won't follow this switch. Restart the session for full reset.",
);
}
return { info: lines.join("\n") };
};
export const handlers: Record<string, SlashHandler> = {
hook: hooks,
hooks,
cwd,
update,
stats,
};
+126
View File
@@ -0,0 +1,126 @@
/**
* `/init` — auto-generate a baseline REASONIX.md by asking the model
* to scan the project and synthesize a concise summary.
*
* Mechanism: the slash handler injects a structured user-turn prompt
* (via `resubmit`) that hard-constrains the model to write a short,
* fact-only document and stop. It uses the same filesystem tools the
* model already has — no new tool, no new pipeline. The model's output
* lands as an `edit_file` (empty SEARCH = create new) or `write_file`
* SEARCH/REPLACE block, which goes through the normal /apply review
* gate so the user can audit before the file hits disk.
*
* Why this shape rather than a CLI command: REASONIX.md goes into
* the system prompt every session, so its content has to fit. A
* model-driven generator that runs inside the same loop the user
* will use later gives the most consistent output, and reusing the
* existing edit-review flow means we don't have to invent a
* separate "review the generated doc" UI.
*/
import { existsSync } from "node:fs";
import * as pathMod from "node:path";
import type { SlashHandler } from "../dispatch.js";
/**
* The init brief. Sent verbatim as a user turn (via resubmit). Hard
* constraints up top because flash will skim and the structure of
* the document matters for system-prompt budget. The "STOP after
* writing" line is load-bearing — without it the model will follow
* up with "want me to also …" and burn turns on noise.
*/
const INIT_PROMPT = [
"# Task: Initialize REASONIX.md",
"",
"I want you to generate a REASONIX.md at the project root that captures",
"the working knowledge a future Reasonix session needs to be productive",
"here. This file is auto-pinned into your system prompt every launch,",
"so its size and accuracy matter.",
"",
"## Hard constraints (do NOT relax these)",
"",
"- **Length cap: ≤ 80 lines / 3KB total.** Be concise. If you can't fit a",
" section, drop it.",
"- **Only document things you can verify by reading files.** Do NOT",
" speculate about architectural intent, future roadmap, or design",
" rationale. If it isn't obvious from the code, leave it out.",
"- **No placeholder text.** No 'TODO: describe X', no 'Add more here'.",
" Either state a fact or omit the section.",
"",
"## Procedure",
"",
"1. Read the top of any existing README* file.",
"2. Read the manifest (package.json / Cargo.toml / pyproject.toml /",
" go.mod / etc.) — pick whichever exists.",
"3. `directory_tree` 1-2 levels deep on the project root, skipping",
" common build/dependency dirs (node_modules, dist, target, .git,",
" venv, __pycache__).",
"4. Identify: primary language + framework, top-level layout, test",
" runner, lint/format setup, build/run/test scripts, any non-obvious",
" convention with visible evidence (commit message format, import",
" order, naming pattern).",
"5. Write REASONIX.md with the sections below, skipping any you can't",
" fill from evidence.",
"",
"## Sections to use (skip ones with no evidence)",
"",
"- **Stack** — language + framework + 3-5 key deps. One line each.",
"- **Layout** — top-level dirs and what lives in each. One line each.",
"- **Commands** — verbatim from `scripts` block (or equivalent):",
" build / test / lint / typecheck / dev / format. Whatever exists.",
"- **Conventions** — only things visible in the code. Examples:",
" '*.test.ts colocated with source', 'named exports only',",
" 'commits use Conventional Commits prefix'. If you can't find any",
" CONVENTION evidence, omit the whole section.",
"- **Watch out for** — gotchas a new contributor would benefit from",
" knowing BEFORE editing. Examples: 'edit_file SEARCH must match",
" byte-for-byte', 'this dir is generated, don't edit by hand'.",
" Omit if you find nothing concrete.",
"",
"## Output",
"",
"Write the result to `REASONIX.md` in the project root using the",
"filesystem tools (edit_file with empty SEARCH if creating new,",
"write_file if overwriting). After writing, STOP — do not summarize",
"what you did, do not propose follow-up tasks. The user will review",
"the pending edit via /apply.",
"",
"Start now.",
].join("\n");
const init: SlashHandler = (args, _loop, ctx) => {
if (!ctx.codeRoot) {
return {
info: [
"/init only works in code mode (it needs filesystem tools).",
"Run `reasonix code [path]` to start a session rooted at the",
"project you want to initialize, then run /init.",
].join("\n"),
};
}
const force = (args[0] ?? "").toLowerCase() === "force";
const target = pathMod.join(ctx.codeRoot, "REASONIX.md");
if (existsSync(target) && !force) {
return {
info: [
`▸ REASONIX.md already exists at ${target}`,
"",
" /init force regenerate from scratch (overwrites)",
"",
" Or edit it by hand — it's just markdown. The current file is",
" pinned into the system prompt every launch as-is.",
].join("\n"),
};
}
return {
info: [
"▸ /init — model will scan the project and synthesize REASONIX.md.",
" The result lands as a pending edit; review with /apply or /walk.",
].join("\n"),
resubmit: INIT_PROMPT,
};
};
export const handlers: Record<string, SlashHandler> = {
init,
};
+196
View File
@@ -0,0 +1,196 @@
import {
addProjectShellAllowed,
clearProjectShellAllowed,
loadProjectShellAllowed,
removeProjectShellAllowed,
} from "../../../../config.js";
import { BUILTIN_ALLOWLIST } from "../../../../tools/shell.js";
import type { SlashHandler } from "../dispatch.js";
/**
* `/permissions` family. Mirrors /memory's shape — bare lists state,
* subcommands manage it. Two layers exist:
*
* 1. Builtin allowlist — baked into shell.ts (`BUILTIN_ALLOWLIST`),
* always on. Read-only here.
* 2. Per-project allowlist — `~/.reasonix/config.json`, populated by
* "always allow" picks on ShellConfirm. Add / remove / clear here.
*
* Edit-mode (review / auto / yolo) is also surfaced at the top so users
* see "yolo skips all of this" without having to re-derive it from /mode.
*
* Subcommands:
* add <prefix...> — append a prefix (multi-token OK: "git push origin")
* remove <prefix|N> — drop by exact prefix or 1-based project index
* clear confirm — wipe every project entry (typed literal "confirm")
*/
const permissions: SlashHandler = (args, _loop, ctx) => {
const sub = (args[0] ?? "").toLowerCase();
const root = ctx.codeRoot;
const mode = ctx.editMode ?? null;
// Bare → status + listing.
if (sub === "" || sub === "list" || sub === "ls") {
return { info: renderListing(root, mode) };
}
// Mutating subcommands need a code-mode root.
if (!root) {
return {
info: "/permissions add / remove / clear are only available inside `reasonix code` — they edit the project-scoped allowlist (`~/.reasonix/config.json` projects[<root>].shellAllowed).",
};
}
if (sub === "add") {
const prefix = args.slice(1).join(" ").trim();
if (!prefix) {
return {
info: 'usage: /permissions add <prefix> (multi-token OK: /permissions add "git push origin")',
};
}
const before = loadProjectShellAllowed(root);
if (before.includes(prefix)) {
return { info: `▸ already allowed: ${prefix}` };
}
if (BUILTIN_ALLOWLIST.includes(prefix)) {
return {
info: `▸ \`${prefix}\` is already in the builtin allowlist — no per-project entry needed. (Builtin entries are always on.)`,
};
}
addProjectShellAllowed(root, prefix);
return {
info: `▸ added: ${prefix}\n → next \`${prefix}\` invocation runs without prompting in this project.`,
};
}
if (sub === "remove" || sub === "rm" || sub === "delete") {
const target = args.slice(1).join(" ").trim();
if (!target) {
return {
info: "usage: /permissions remove <prefix-or-index> (e.g. /permissions remove 3, or /permissions remove npm)",
};
}
const existing = loadProjectShellAllowed(root);
let prefix: string | null = null;
// Index form: "/permissions remove 3" → 1-based project index.
if (/^\d+$/.test(target)) {
const idx = Number.parseInt(target, 10);
if (idx < 1 || idx > existing.length) {
return {
info:
existing.length === 0
? "▸ no project allowlist entries to remove."
: `▸ index out of range: ${idx} (project list has ${existing.length} entries)`,
};
}
prefix = existing[idx - 1] ?? null;
} else {
prefix = target;
}
if (prefix === null) return { info: "▸ nothing to remove." };
// Builtin entries can't be touched — surface that explicitly.
if (BUILTIN_ALLOWLIST.includes(prefix) && !existing.includes(prefix)) {
return {
info: `▸ \`${prefix}\` is in the builtin allowlist (read-only). Builtin entries can't be removed at runtime — they're baked into the binary.`,
};
}
const ok = removeProjectShellAllowed(root, prefix);
return {
info: ok
? `▸ removed: ${prefix}`
: `▸ no such project entry: ${prefix} (try /permissions list to see what's stored)`,
};
}
if (sub === "clear") {
if ((args[1] ?? "").toLowerCase() !== "confirm") {
const count = loadProjectShellAllowed(root).length;
return {
info:
count === 0
? "▸ project allowlist is already empty."
: `about to drop ${count} project allowlist entr${count === 1 ? "y" : "ies"} for ${root}. Re-run with the word 'confirm' to proceed: /permissions clear confirm`,
};
}
const dropped = clearProjectShellAllowed(root);
return {
info:
dropped === 0
? "▸ project allowlist was already empty — nothing changed."
: `▸ cleared ${dropped} project allowlist entr${dropped === 1 ? "y" : "ies"}.`,
};
}
return {
info: [
"usage: /permissions [list] show current state",
' /permissions add <prefix> persist (e.g. "npm run build")',
" /permissions remove <prefix-or-N> drop one entry",
" /permissions clear confirm wipe every project entry",
].join("\n"),
};
};
function renderListing(root: string | undefined, mode: string | null): string {
const lines: string[] = [];
// Mode banner — edit-gate is the dial that decides whether the
// allowlist matters for the next call.
if (mode === "yolo") {
lines.push(
"▸ edit mode: YOLO — every shell command auto-runs, allowlist is bypassed. /mode review to re-enable prompts.",
);
} else if (mode === "auto") {
lines.push(
"▸ edit mode: auto — edits auto-apply, shell still gated by allowlist (or ShellConfirm prompt for non-allowlisted).",
);
} else if (mode === "review") {
lines.push(
"▸ edit mode: review — both edits and non-allowlisted shell commands ask before running.",
);
}
lines.push("");
// Project list.
if (root) {
const project = loadProjectShellAllowed(root);
lines.push(`Project allowlist (${project.length}) — ${root}`);
if (project.length === 0) {
lines.push(' (none — pick "always allow" on a ShellConfirm prompt to add one,');
lines.push(" or `/permissions add <prefix>` directly.)");
} else {
project.forEach((p, i) => {
lines.push(` ${String(i + 1).padStart(2)}. ${p}`);
});
}
} else {
lines.push("Project allowlist — (no project root; chat mode shows builtin entries only)");
}
lines.push("");
// Builtin list — grouped by leading verb so 40 lines stay scannable.
lines.push(`Builtin allowlist (${BUILTIN_ALLOWLIST.length}) — read-only, baked in`);
const grouped = new Map<string, string[]>();
for (const entry of BUILTIN_ALLOWLIST) {
const head = entry.split(" ")[0] ?? entry;
if (!grouped.has(head)) grouped.set(head, []);
grouped.get(head)!.push(entry);
}
for (const [head, items] of grouped) {
if (items.length === 1 && items[0] === head) {
lines.push(` · ${head}`);
} else {
const tail = items.map((i) => i.slice(head.length).trim() || "(bare)").join(", ");
lines.push(` · ${head}: ${tail}`);
}
}
lines.push("");
lines.push(
"Subcommands: /permissions add <prefix> · /permissions remove <prefix-or-N> · /permissions clear confirm",
);
return lines.join("\n");
}
export const handlers: Record<string, SlashHandler> = {
permissions,
perms: permissions,
};
+133
View File
@@ -0,0 +1,133 @@
/**
* `/semantic` slash — show whether semantic_search is set up for
* this project, and what to run if not.
*
* Behavior is purely informational. The actual setup happens via
* `reasonix index` in the user's shell — same pattern `/setup` and
* `/update` use. Doing the install / build inline would mean
* suspending Ink and bringing it back, which is fragile (especially
* the ANSI-cursor-up paths Windows shells already mishandle).
*
* All user-facing strings come from the i18n table so Chinese-locale
* users see Chinese; everyone else gets English.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { probeOllama } from "../../../../index/semantic/embedding.js";
import { t } from "../../../../index/semantic/i18n.js";
import { findOllamaBinary } from "../../../../index/semantic/ollama-launcher.js";
import type { SlashHandler } from "../dispatch.js";
const semantic: SlashHandler = (_args, _loop, ctx) => {
const root = ctx.codeRoot;
if (!root) {
return {
info: "/semantic is only available inside `reasonix code` (needs a project root).",
};
}
// Fire-and-forget: probes (file stat, optional Ollama HTTP) take
// ~50–200ms which is too long to block the prompt. Same pattern
// /kill uses — return a placeholder, post the rich result through
// ctx.postInfo when ready. ctx.postInfo is wired by the TUI when
// it owns historical rendering.
void (async () => {
const status = await renderSemanticStatus(root);
ctx.postInfo?.(status);
})();
return { info: "▸ checking semantic_search status…" };
};
/**
* Async status renderer. The slash returns a synchronous
* "fetching…" placeholder, then this function — invoked via
* ctx.postInfo from the slash dispatcher wrapper — posts the real
* status when probes finish. Keeps the slash handler signature sync
* (the established pattern across the codebase).
*
* Exposed separately so tests can call it without faking the slash
* machinery.
*/
export async function renderSemanticStatus(rootDir: string): Promise<string> {
const lines: string[] = [t("slashHeader"), ""];
const indexExists = await indexFileExists(rootDir);
if (indexExists) {
const meta = await readIndexMeta(rootDir);
lines.push(t("slashEnabled"));
if (meta) {
lines.push(
t("slashEnabledDetail", {
chunks: meta.chunks,
files: meta.files,
}),
);
}
lines.push(t("slashEnabledHowto"));
return lines.join("\n");
}
// Not built yet. Walk the prerequisites in priority order.
lines.push(t("slashIndexMissing"));
lines.push(t("slashIndexInfo"));
lines.push("");
if (findOllamaBinary() === null) {
lines.push(t("slashOllamaMissing"));
} else {
const probe = await probeOllama();
if (!probe.ok) lines.push(t("slashDaemonDown"));
}
lines.push(t("slashHowToBuild"));
return lines.join("\n");
}
async function indexFileExists(rootDir: string): Promise<boolean> {
try {
await fs.access(path.join(rootDir, ".reasonix", "semantic", "index.meta.json"));
return true;
} catch {
return false;
}
}
interface IndexSummary {
chunks: number;
files: number;
}
/**
* Cheap summary of the index for the `/semantic` status panel.
* Counts JSONL lines (each = one chunk) and unique paths via a Set.
* For typical projects (<10k chunks) this is well under 50ms; for
* very large repos we cap reads at 10MB to keep `/semantic` snappy.
*/
async function readIndexMeta(rootDir: string): Promise<IndexSummary | null> {
const dataPath = path.join(rootDir, ".reasonix", "semantic", "index.jsonl");
try {
const stat = await fs.stat(dataPath);
if (stat.size > 10 * 1024 * 1024) {
// For huge indexes, give an order-of-magnitude estimate from
// file size (avg ~500 bytes/chunk in practice). Files won't
// be available, so we report just the chunk approximation.
return { chunks: Math.round(stat.size / 500), files: 0 };
}
const raw = await fs.readFile(dataPath, "utf8");
const seenPaths = new Set<string>();
let chunks = 0;
for (const line of raw.split("\n")) {
if (line.length === 0) continue;
chunks++;
try {
const parsed = JSON.parse(line) as { p?: string };
if (parsed.p) seenPaths.add(parsed.p);
} catch {
/* tolerated — store rebuilds drop bad lines */
}
}
return { chunks, files: seenPaths.size };
} catch {
return null;
}
}
export const handlers: Record<string, SlashHandler> = {
semantic,
};
+10
View File
@@ -174,6 +174,16 @@ export interface SlashContext {
* confirmation. Absent → `/hooks reload` replies "not available".
*/
reloadHooks?: () => number;
/**
* Switch the session working directory. Called by `/cwd <path>`.
* Updates the hook cwd, memory root, project shell allowlist root,
* `@file` mention root, and (in code mode) re-registers filesystem /
* shell / memory / skill tools against the new path. Returns a
* human-readable info line describing what changed; throws on
* validation errors so the handler can surface the message.
* Absent → `/cwd` replies "not available in this context".
*/
setCwd?: (path: string) => string;
/**
* Latest published version if App's background registry check
* has completed, `null` otherwise (still in flight OR offline).
+13 -5
View File
@@ -17,6 +17,13 @@ export interface UseCompletionPickersParams {
input: string;
setInput: (v: string) => void;
codeMode: { rootDir: string } | undefined;
/**
* Live working directory for the @-mention file picker. May differ
* from `codeMode.rootDir` after a `/cwd` switch — `codeMode` keeps
* the launch root for "is this a code-mode session?" checks while
* this drives the actual file listing.
*/
rootDir: string;
models: string[] | null;
mcpServers: McpServerSummary[] | undefined;
}
@@ -57,6 +64,7 @@ export function useCompletionPickers({
input,
setInput,
codeMode,
rootDir,
models,
mcpServers,
}: UseCompletionPickersParams): UseCompletionPickersResult {
@@ -81,13 +89,13 @@ export function useCompletionPickers({
// 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 [];
if (!codeMode) return [];
try {
return listFilesWithStatsSync(codeMode.rootDir, { maxResults: 500 });
return listFilesWithStatsSync(rootDir, { maxResults: 500 });
} catch {
return [];
}
}, [codeMode?.rootDir]);
}, [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.
@@ -100,12 +108,12 @@ export function useCompletionPickers({
if (list.length > 20) list.length = 20;
}, []);
const atPicker = useMemo(() => {
if (!codeMode?.rootDir) return null;
if (!codeMode) 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]);
}, [codeMode, input, slashMatches]);
const atMatches = useMemo<readonly string[] | null>(() => {
if (!atPicker) return null;
return rankPickerCandidates(atFiles, atPicker.query, {
+44 -2
View File
@@ -156,6 +156,16 @@ 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.
# When the user wants to switch project / working directory
If the user asks to switch / change / open a different directory or project ("切换到...", "switch to ...", "let's work in ...", "open the X project"), call **\`change_workspace\`** with the absolute target path. The tool always requires the user's explicit approval via a TUI modal — your call surfaces a "switch / deny" prompt, and STOPS your turn until they pick. After approval the filesystem / shell / memory tools re-register against the new root and your subsequent calls land there.
Hard rules:
- Do NOT try to switch via \`run_command\` (\`cd\`, \`pushd\`, etc.) — your tool sandbox is pinned and \`cd\` inside one shell call doesn't carry to the next.
- Do NOT chain other tool calls in the same turn as \`change_workspace\` — wait for the user's confirmation. Their next message will tell you whether the switch happened.
- Do NOT call \`change_workspace\` to "preview" a sibling directory; only when the user explicitly asked to change projects.
- The user can also type \`/cwd <path>\` themselves — fine, you'll see the new root take effect on the next turn either way.
# Foreground vs. background commands
You have TWO tools for running shell commands, and picking the right one is non-negotiable:
@@ -210,8 +220,40 @@ ${TUI_FORMATTING_RULES}
* Stacking order (stable for cache prefix):
* base prompt → REASONIX.md → global MEMORY.md → project MEMORY.md → .gitignore
*/
export function codeSystemPrompt(rootDir: string): string {
const withMemory = applyMemoryStack(CODE_SYSTEM_PROMPT, rootDir);
/**
* Routing fragment appended to the code-mode system prompt when the
* project has a semantic index built. Without this nudge the model
* defaults to grep (`search_content`) for every query — semantic
* search loses the "first attempt" battle on token-rich phrasings
* even when the question is descriptive.
*
* Kept short and explicit: rules over rationale. The model that needs
* this hint isn't reading three paragraphs of "why semantic search
* is good"; it needs a clear if/else.
*/
const SEMANTIC_SEARCH_ROUTING = `
# Search routing
You have BOTH \`semantic_search\` (vector index) and \`search_content\` (literal grep).
- **Descriptive queries** ("where do we handle X", "which file owns Y", "how does Z work", "find the logic that does …", "the code responsible for …") → call \`semantic_search\` FIRST. It indexes the project by meaning, so it finds the right file even when your phrasing shares no tokens with the code.
- **Exact-token queries** (a specific identifier, regex, or "find every call to foo") → call \`search_content\`.
If \`semantic_search\` returns nothing useful (low scores, off-topic), THEN fall back to \`search_content\`. Don't go the other way — grepping a paraphrased question wastes turns.`;
export interface CodeSystemPromptOptions {
/** True when semantic_search is registered for this run. Adds an
* explicit routing fragment so the model picks it for intent-style
* queries instead of defaulting to grep. */
hasSemanticSearch?: boolean;
}
export function codeSystemPrompt(rootDir: string, opts: CodeSystemPromptOptions = {}): string {
const base = opts.hasSemanticSearch
? `${CODE_SYSTEM_PROMPT}${SEMANTIC_SEARCH_ROUTING}`
: CODE_SYSTEM_PROMPT;
const withMemory = applyMemoryStack(base, rootDir);
const gitignorePath = join(rootDir, ".gitignore");
if (!existsSync(gitignorePath)) return withMemory;
let content: string;
+44
View File
@@ -193,6 +193,50 @@ export function addProjectShellAllowed(
writeConfig(cfg, path);
}
/**
* Drop one prefix from the project's shell-allowed list. Returns true
* when the prefix was present (and removed), false when it wasn't —
* lets `/permissions remove` distinguish "removed" from "no such entry"
* without a separate read. Match is exact after trim; we deliberately
* do NOT prefix-match here (e.g. removing `git` shouldn't also drop
* `git push origin main` if the user added both).
*/
export function removeProjectShellAllowed(
rootDir: string,
prefix: string,
path: string = defaultConfigPath(),
): boolean {
const trimmed = prefix.trim();
if (!trimmed) return false;
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
if (!existing.includes(trimmed)) return false;
const next = existing.filter((p) => p !== trimmed);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = next;
writeConfig(cfg, path);
return true;
}
/**
* Wipe every persisted shell-allow entry for a project. Returns the
* number of prefixes that were dropped — zero if nothing was stored.
*/
export function clearProjectShellAllowed(
rootDir: string,
path: string = defaultConfigPath(),
): number {
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
if (existing.length === 0) return 0;
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = [];
writeConfig(cfg, path);
return existing.length;
}
/**
* 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
+8 -1
View File
@@ -200,6 +200,8 @@ export { StdioTransport } from "./mcp/stdio.js";
export type { McpTransport, StdioTransportOptions } from "./mcp/stdio.js";
export { SseTransport } from "./mcp/sse.js";
export type { SseTransportOptions } from "./mcp/sse.js";
export { StreamableHttpTransport } from "./mcp/streamable-http.js";
export type { StreamableHttpTransportOptions } from "./mcp/streamable-http.js";
export {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RESULT_TOKENS,
@@ -210,7 +212,12 @@ export {
} from "./mcp/registry.js";
export type { BridgeOptions, BridgeResult, FlattenOptions } from "./mcp/registry.js";
export { parseMcpSpec } from "./mcp/spec.js";
export type { McpSpec, StdioMcpSpec, SseMcpSpec } from "./mcp/spec.js";
export type {
McpSpec,
SseMcpSpec,
StdioMcpSpec,
StreamableHttpMcpSpec,
} from "./mcp/spec.js";
export { inspectMcpServer } from "./mcp/inspect.js";
export type { InspectionReport, SectionResult } from "./mcp/inspect.js";
+246
View File
@@ -0,0 +1,246 @@
/**
* Build + query orchestrator for the semantic index.
*
* `buildIndex(root, opts)` walks the project, embeds new/changed
* files, and persists. It is incremental by default — files whose
* mtime hasn't moved since the last build are skipped, which is the
* main reason the index can be kept fresh as a `Stop` hook or a
* pre-`reasonix code` warm-up.
*
* `querySemantic(root, query, opts)` opens the on-disk index, embeds
* the query, and returns top-K hits formatted for tool output. No
* mutation; safe to call from any tool fn.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { walkChunks } from "./chunker.js";
import type { CodeChunk } from "./chunker.js";
import { embed, embedAll, probeOllama } from "./embedding.js";
import type { EmbedOptions } from "./embedding.js";
import { normalize, openStore } from "./store.js";
import type { IndexEntry, SearchHit } from "./store.js";
/** Default index dir relative to the project root. */
export const INDEX_DIR_NAME = path.join(".reasonix", "semantic");
export interface BuildOptions extends EmbedOptions {
/** Skip files larger than this many bytes. Default 256 KB. */
maxFileBytes?: number;
/** Lines per window. Default 60. */
windowLines?: number;
/** Window overlap. Default 12. */
overlap?: number;
/** Force a full rebuild (drop the existing index first). */
rebuild?: boolean;
/** Progress callback for the CLI to render counters. */
onProgress?: (info: BuildProgress) => void;
}
export interface BuildProgress {
phase: "scan" | "embed" | "write" | "done";
filesScanned?: number;
chunksTotal?: number;
chunksDone?: number;
filesSkipped?: number;
filesChanged?: number;
}
export interface BuildResult {
filesScanned: number;
filesChanged: number;
chunksAdded: number;
chunksRemoved: number;
/** Chunks that failed to embed (Ollama 500, transient errors) and
* were skipped. Reported in the success line so users notice. */
chunksSkipped: number;
durationMs: number;
}
/**
* Build (or incrementally update) the semantic index for `root`.
* Probes Ollama first so a missing daemon fails before any chunking.
*/
export async function buildIndex(root: string, opts: BuildOptions = {}): Promise<BuildResult> {
const t0 = Date.now();
const indexDir = path.join(root, INDEX_DIR_NAME);
const probe = await probeOllama({ baseUrl: opts.baseUrl, signal: opts.signal });
if (!probe.ok) {
throw new Error(
`Ollama is not reachable: ${probe.error}. Install from https://ollama.com, then \`ollama serve\` and \`ollama pull ${opts.model ?? "nomic-embed-text"}\`.`,
);
}
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text";
const store = await openStore(indexDir, model);
if (opts.rebuild) await store.wipe();
// Snapshot the index's per-file mtimes so we can detect (a) changed
// files (mtime moved) and (b) deleted files (path no longer exists
// on disk after the walk).
const lastMtimes = store.fileMtimes();
const seenPaths = new Set<string>();
// Phase 1 — scan + chunk + collect mtime per file. Buffer chunks
// by file because we need to drop+re-add per file when its mtime
// changes (a partial update would leave stale chunks).
const fileChunks = new Map<string, { chunks: CodeChunk[]; mtimeMs: number }>();
let filesScanned = 0;
let filesSkipped = 0;
for await (const chunk of walkChunks(root, {
windowLines: opts.windowLines,
overlap: opts.overlap,
maxFileBytes: opts.maxFileBytes,
})) {
seenPaths.add(chunk.path);
let bucket = fileChunks.get(chunk.path);
if (!bucket) {
filesScanned++;
const abs = path.join(root, chunk.path);
let mtimeMs = 0;
try {
const stat = await fs.stat(abs);
mtimeMs = stat.mtimeMs;
} catch {
continue;
}
const last = lastMtimes.get(chunk.path);
if (last !== undefined && last === mtimeMs && !opts.rebuild) {
filesSkipped++;
continue; // Unchanged — skip embedding.
}
bucket = { chunks: [], mtimeMs };
fileChunks.set(chunk.path, bucket);
}
bucket.chunks.push(chunk);
opts.onProgress?.({ phase: "scan", filesScanned });
}
// Phase 2 — drop entries for files that disappeared (rename/delete).
const deletedPaths: string[] = [];
for (const oldPath of lastMtimes.keys()) {
if (!seenPaths.has(oldPath)) deletedPaths.push(oldPath);
}
// Files whose chunks we re-built also need their old entries
// evicted before the new ones are inserted, otherwise the index
// grows duplicate snippets for the same range.
const replacePaths = [...fileChunks.keys()].filter((p) => lastMtimes.has(p));
const removed = await store.remove([...deletedPaths, ...replacePaths]);
// Phase 3 — embed buffered chunks file by file. Sequential per
// file so the progress counter advances visibly; embedAll is
// sequential internally (Ollama serializes anyway). Per-chunk
// failures (Ollama 500, transient errors) are logged + skipped
// via embedAll's null-slot convention so a single bad chunk
// doesn't kill a long-running build.
let chunksAdded = 0;
let chunksSkipped = 0;
const filesChanged = fileChunks.size;
let chunksTotal = 0;
for (const { chunks } of fileChunks.values()) chunksTotal += chunks.length;
let chunksDone = 0;
for (const [, bucket] of fileChunks) {
if (bucket.chunks.length === 0) continue;
const texts = bucket.chunks.map((c) => c.text);
const vectors = await embedAll(texts, {
...opts,
onProgress: (done, total) => {
opts.onProgress?.({
phase: "embed",
filesScanned,
filesChanged,
chunksTotal,
chunksDone: chunksDone + done,
});
if (done === total) chunksDone += total;
},
onError: (idx, err) => {
chunksSkipped++;
const c = bucket.chunks[idx];
const where = c ? `${c.path}:${c.startLine}-${c.endLine}` : `chunk #${idx}`;
const msg = err instanceof Error ? err.message : String(err);
// stderr only — non-fatal warnings shouldn't pollute stdout
// (which the rest of the CLI keeps clean for piping).
process.stderr.write(`\n ! skipped ${where}: ${msg}\n`);
},
});
const entries: IndexEntry[] = [];
for (let i = 0; i < bucket.chunks.length; i++) {
const vec = vectors[i];
if (!vec) continue; // skipped due to per-chunk error
const c = bucket.chunks[i];
if (!c) continue;
normalize(vec);
entries.push({
path: c.path,
startLine: c.startLine,
endLine: c.endLine,
text: c.text,
embedding: vec,
mtimeMs: bucket.mtimeMs,
});
}
if (entries.length > 0) await store.add(entries);
chunksAdded += entries.length;
}
opts.onProgress?.({
phase: "done",
filesScanned,
filesSkipped,
filesChanged,
chunksTotal,
chunksDone,
});
return {
filesScanned,
filesChanged,
chunksAdded,
chunksRemoved: removed,
chunksSkipped,
durationMs: Date.now() - t0,
};
}
export interface QueryOptions extends EmbedOptions {
topK?: number;
/** Drop hits below this cosine score. Default 0.3 — anything weaker is noise. */
minScore?: number;
}
/**
* Embed `query` and return ranked hits from the index. Returns
* `null` when no index exists for `root` so the caller can decide
* whether to fall back to grep + a "run reasonix index" hint.
*/
export async function querySemantic(
root: string,
query: string,
opts: QueryOptions = {},
): Promise<SearchHit[] | null> {
const indexDir = path.join(root, INDEX_DIR_NAME);
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text";
const store = await openStore(indexDir, model);
if (store.empty) return null;
const qvec = await embed(query, opts);
normalize(qvec);
return store.search(qvec, opts.topK ?? 8, opts.minScore ?? 0.3);
}
/**
* Cheap synchronous-ish probe — returns true if the index dir
* contains an index.meta.json. Used to gate `semantic_search` tool
* registration: no index → no tool (model can't call something it
* can't use).
*/
export async function indexExists(root: string): Promise<boolean> {
const meta = path.join(root, INDEX_DIR_NAME, "index.meta.json");
try {
await fs.access(meta);
return true;
} catch {
return false;
}
}
+319
View File
@@ -0,0 +1,319 @@
/**
* Code chunker — walks a directory and splits source files into
* embedding-sized windows with `{path, startLine, endLine, text}`
* metadata so semantic-search results carry a precise file:line
* citation back into the conversation.
*
* Why line-window (not AST) for MVP:
* - Language-agnostic: works on TS, Py, Rust, Go, Markdown,
* config files — no per-language parser to ship and maintain.
* - Predictable cost: a 500-line file produces ~10 chunks at
* `windowLines=60, overlap=12`. Embedding cost scales linearly
* with file size, no surprises.
* - Cite-friendly: every chunk has exact line range. Click-through
* in the UI is `path:startLine`.
*
* AST-aware chunking (split per function/class) is a real upgrade
* for retrieval quality and is on the post-MVP list.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
export interface CodeChunk {
/** Path relative to the index root, forward slashes. Stable across OS. */
path: string;
/** 1-based, inclusive. */
startLine: number;
endLine: number;
text: string;
}
export interface ChunkOptions {
/** Lines per window. Default 60. */
windowLines?: number;
/** Lines of overlap between consecutive windows. Default 12. */
overlap?: number;
/** Skip files larger than this (bytes). Default 256 KB. */
maxFileBytes?: number;
/**
* Hard ceiling on chunk text length, in characters. A 60-line slice
* through a JSON blob or minified bundle can blow past nomic-embed-
* text's context window (8K tokens, ~32K chars worst-case but as
* little as 8K chars for non-English / dense token streams).
* Default 4000 — comfortably under 1024 tokens for most code, with
* room to spare for tokenizers that fragment unicode aggressively.
*/
maxChunkChars?: number;
}
/** Default character cap per chunk — sized for nomic-embed-text. */
export const DEFAULT_MAX_CHUNK_CHARS = 4000;
/**
* Default skip-list. Mirrors what `directory_tree` already prunes
* plus binary extensions that have zero retrieval value (and waste
* embedding tokens). Keep this in sync with src/tools/filesystem.ts
* if either drifts.
*/
const SKIP_DIRS: ReadonlySet<string> = new Set([
"node_modules",
".git",
"dist",
"build",
"out",
".next",
".nuxt",
"target",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".cache",
"coverage",
".turbo",
".vercel",
".reasonix",
]);
const SKIP_FILES: ReadonlySet<string> = new Set([
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"poetry.lock",
"Pipfile.lock",
"go.sum",
".DS_Store",
]);
const BINARY_EXTS: ReadonlySet<string> = new Set([
// Images
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".ico",
".tiff",
// Fonts
".woff",
".woff2",
".ttf",
".otf",
".eot",
// Archives / binaries
".zip",
".tar",
".gz",
".rar",
".7z",
".exe",
".dll",
".so",
".dylib",
".class",
".jar",
".wasm",
".o",
".a",
// Media
".mp3",
".mp4",
".wav",
".ogg",
".webm",
".mov",
// Other
".pdf",
".sqlite",
".db",
]);
/**
* Split file content into overlapping line windows. `overlap`
* preserves context across chunk boundaries — a function whose
* signature is on the boundary still appears in two adjacent chunks
* so a query about it ranks both.
*
* Any window whose text exceeds `maxChunkChars` is post-split into
* multiple sub-chunks at line boundaries (or hard-truncated for a
* single overlong line) so every yielded chunk is safe to embed.
*/
export function chunkText(
text: string,
filePath: string,
windowLines: number,
overlap: number,
maxChunkChars: number = DEFAULT_MAX_CHUNK_CHARS,
): CodeChunk[] {
const lines = text.split(/\r?\n/);
if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) return [];
const stride = Math.max(1, windowLines - overlap);
const chunks: CodeChunk[] = [];
for (let start = 0; start < lines.length; start += stride) {
const end = Math.min(lines.length, start + windowLines);
const slice = lines.slice(start, end).join("\n").trim();
if (slice.length === 0) {
if (end >= lines.length) break;
continue;
}
const window: CodeChunk = {
path: filePath,
startLine: start + 1,
endLine: end,
text: slice,
};
for (const sub of safeSplit(window, maxChunkChars)) chunks.push(sub);
if (end >= lines.length) break;
}
return chunks;
}
/**
* Post-process a window so no chunk exceeds `maxChars`. We try to
* preserve full-line granularity for retrieval quality:
*
* - Walk the window line by line, accumulating into a buffer until
* adding the next line would exceed `maxChars`. Flush the buffer
* as a sub-chunk, restart at that line.
* - A single line longer than `maxChars` (long minified statement
* / base64 blob / unicode-heavy line) gets emitted as its own
* chunk, hard-truncated to `maxChars`. Coverage loss is acceptable
* — minified content has near-zero retrieval value.
*
* Idempotent: chunks already under `maxChars` pass through unchanged
* (well — the buffer logic still emits them as a single sub-chunk,
* but the result is byte-equivalent).
*/
function safeSplit(chunk: CodeChunk, maxChars: number): CodeChunk[] {
if (chunk.text.length <= maxChars) return [chunk];
const lines = chunk.text.split("\n");
const out: CodeChunk[] = [];
let bufLines: string[] = [];
let bufStart = chunk.startLine;
let bufLen = 0;
const flush = (untilLineNo: number): void => {
if (bufLines.length === 0) return;
out.push({
path: chunk.path,
startLine: bufStart,
endLine: untilLineNo,
text: bufLines.join("\n"),
});
bufLines = [];
bufLen = 0;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const lineLen = line.length + 1; // +newline
if (lineLen > maxChars) {
// Single line dwarfs the cap. Flush current buffer, then emit
// a hard-truncated single-line chunk for this line.
flush(chunk.startLine + i - 1);
out.push({
path: chunk.path,
startLine: chunk.startLine + i,
endLine: chunk.startLine + i,
text: line.slice(0, maxChars),
});
bufStart = chunk.startLine + i + 1;
continue;
}
if (bufLen + lineLen > maxChars && bufLines.length > 0) {
flush(chunk.startLine + i - 1);
bufStart = chunk.startLine + i;
}
bufLines.push(line);
bufLen += lineLen;
}
flush(chunk.endLine);
return out;
}
/**
* Walk `root` recursively and yield code chunks for every indexable
* file. Pure async generator so callers can pipe through embedding +
* progress reporting without buffering the whole repo in memory.
*
* The generator silently skips files larger than `maxFileBytes` —
* generated bundles, vendored data, etc. — to avoid one huge file
* dominating the budget. The threshold is intentionally low; the
* real signal in oversized files is rare and the embedding cost
* scales with chunk count.
*/
export async function* walkChunks(
root: string,
opts: ChunkOptions = {},
): AsyncGenerator<CodeChunk> {
const windowLines = opts.windowLines ?? 60;
const overlap = Math.min(opts.overlap ?? 12, Math.max(0, windowLines - 1));
const maxFileBytes = opts.maxFileBytes ?? 256 * 1024;
const maxChunkChars = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop();
if (!dir) break;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const name = entry.name;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(name) || name.startsWith(".")) {
// .git/.next/.cache etc. plus any hidden dir — same default
// as `directory_tree`. Users opt in to dotfiles via a future
// flag; MVP keeps the index lean.
if (SKIP_DIRS.has(name) || name === ".git") continue;
// Allow other dotdirs that aren't in SKIP_DIRS (rare).
}
stack.push(path.join(dir, name));
continue;
}
if (!entry.isFile()) continue;
if (SKIP_FILES.has(name)) continue;
const ext = path.extname(name).toLowerCase();
if (BINARY_EXTS.has(ext)) continue;
const abs = path.join(dir, name);
let stat: import("node:fs").Stats;
try {
stat = await fs.stat(abs);
} catch {
continue;
}
if (stat.size > maxFileBytes) continue;
// Read as utf-8. If decoding ever fails (random binary that
// sneaks past the ext check), skip rather than crashing the
// whole index build.
let text: string;
try {
text = await fs.readFile(abs, "utf8");
} catch {
continue;
}
// Quick null-byte sniff — catches binary files with weird /
// missing extensions that BINARY_EXTS missed.
if (text.indexOf("\0") !== -1) continue;
const rel = path.relative(root, abs).split(path.sep).join("/");
for (const chunk of chunkText(text, rel, windowLines, overlap, maxChunkChars)) {
yield chunk;
}
}
}
}
/**
* Convenience: collect all chunks from a directory into an array.
* For very large repos prefer streaming via `walkChunks`.
*/
export async function chunkDirectory(root: string, opts: ChunkOptions = {}): Promise<CodeChunk[]> {
const out: CodeChunk[] = [];
for await (const c of walkChunks(root, opts)) out.push(c);
return out;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Embedding adapter — wraps Ollama's `/api/embeddings` endpoint.
*
* Ollama is the MVP backend choice for three reasons that match
* Reasonix's pillars:
* 1. **Local + zero telemetry** — runs on the user's machine, no
* data ever leaves it. Same property as the rest of Reasonix.
* 2. **Zero native deps for us** — we talk plain HTTP+JSON; no
* ONNX runtime / WASM blob / platform-specific binary to ship.
* `npm install reasonix` stays trivial.
* 3. **Reuse across projects** — if the user already has Ollama
* installed (likely for other LLM work), we cost them nothing
* to add. If they don't, one `ollama pull <model>` is the
* whole onboarding.
*
* Default model is `nomic-embed-text` — small (137M params), 768-dim
* output, free, fast. Configurable via `embedModel` option for users
* who want bigger / multilingual / domain-specific embedders.
*/
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
const DEFAULT_EMBED_MODEL = "nomic-embed-text";
const DEFAULT_TIMEOUT_MS = 30_000;
export interface EmbedOptions {
/** Ollama base URL. Default `http://localhost:11434`. Override via env or config for remote daemons. */
baseUrl?: string;
/** Embedding model name. Default `nomic-embed-text`. */
model?: string;
/** Per-request timeout in ms. Default 30s. */
timeoutMs?: number;
/** Caller-controlled abort. */
signal?: AbortSignal;
}
export class EmbeddingError extends Error {
constructor(
message: string,
public override readonly cause?: unknown,
) {
super(message);
this.name = "EmbeddingError";
}
}
/**
* Embed a single text. Returns a Float32Array (compact, fits the
* cosine-scan inner loop), not a number[]. Throws `EmbeddingError`
* with an actionable message when Ollama is unreachable so the
* caller can surface install instructions instead of a generic
* `fetch failed`.
*/
export async function embed(text: string, opts: EmbedOptions = {}): Promise<Float32Array> {
const baseUrl = opts.baseUrl ?? process.env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL;
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// Compose abort: caller's signal + our timeout. Either firing
// tears down the request.
const controller = new AbortController();
const onCallerAbort = () => controller.abort(opts.signal?.reason);
if (opts.signal) {
if (opts.signal.aborted) controller.abort(opts.signal.reason);
else opts.signal.addEventListener("abort", onCallerAbort, { once: true });
}
const timer = setTimeout(() => controller.abort(new Error("embedding timeout")), timeoutMs);
let res: Response;
try {
res = await fetch(`${baseUrl}/api/embeddings`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model, prompt: text }),
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (opts.signal) opts.signal.removeEventListener("abort", onCallerAbort);
// Connection refused (ECONNREFUSED) is the load-bearing case:
// Ollama daemon isn't running. Surface the install hint clearly
// so users don't have to grep a generic node:fetch error.
const msg = err instanceof Error ? err.message : String(err);
if (/ECONNREFUSED|connect ECONNREFUSED|fetch failed/i.test(msg)) {
throw new EmbeddingError(
`Cannot reach Ollama at ${baseUrl}. Install from https://ollama.com, then run \`ollama pull ${model}\` and \`ollama serve\`. Override the URL via OLLAMA_URL.`,
err,
);
}
throw new EmbeddingError(`embedding request failed: ${msg}`, err);
} finally {
clearTimeout(timer);
if (opts.signal) opts.signal.removeEventListener("abort", onCallerAbort);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
// 404 from Ollama with `model not found` body means user needs
// to pull the model. Same logic as the install hint above.
if (res.status === 404 && /model.*not found/i.test(body)) {
throw new EmbeddingError(
`Embedding model "${model}" not pulled. Run \`ollama pull ${model}\` once, then retry.`,
);
}
throw new EmbeddingError(`Ollama returned ${res.status}: ${body.slice(0, 200)}`);
}
const json = (await res.json()) as { embedding?: unknown };
if (!json.embedding || !Array.isArray(json.embedding)) {
throw new EmbeddingError(`Ollama response missing 'embedding' array`);
}
// Convert once to Float32Array so the cosine inner loop avoids
// boxing/unboxing per dot product. 4× memory win over number[] too.
const out = new Float32Array(json.embedding.length);
for (let i = 0; i < json.embedding.length; i++) {
const v = json.embedding[i];
if (typeof v !== "number" || !Number.isFinite(v)) {
throw new EmbeddingError(`embedding[${i}] is not a finite number`);
}
out[i] = v;
}
return out;
}
/**
* Embed many texts sequentially (Ollama's HTTP server is single-
* threaded per model anyway — parallel requests just queue inside
* the daemon). Surfaces progress via `onProgress` and per-text
* failures via `onError`.
*
* Returns one slot per input text. A null slot means that text
* failed to embed (Ollama 500, transient model error, …). Callers
* MUST handle nulls — typically by filtering them out alongside the
* parallel chunk-metadata array. This is the load-bearing fault-
* tolerance that lets one bad chunk (e.g. a malformed file that
* sneaks past size checks, or a transient daemon hiccup) skip the
* chunk instead of aborting a 30-minute index build.
*
* Aborts (signal-driven) still throw — those are global, not
* per-chunk.
*/
export async function embedAll(
texts: readonly string[],
opts: EmbedOptions & {
onProgress?: (done: number, total: number) => void;
/** Fired once per failed chunk with the index + error. Default:
* none — callers can wire it to log a warning. */
onError?: (index: number, err: unknown) => void;
} = {},
): Promise<Array<Float32Array | null>> {
const out: Array<Float32Array | null> = new Array(texts.length).fill(null);
for (let i = 0; i < texts.length; i++) {
if (opts.signal?.aborted) {
throw new EmbeddingError("embedding aborted");
}
const text = texts[i];
if (text === undefined) continue;
try {
out[i] = await embed(text, opts);
} catch (err) {
// Don't throw — surface the error, leave a null slot, and
// continue. If the abort signal fired *during* the request the
// caller will pick that up on the next loop iteration.
opts.onError?.(i, err);
}
opts.onProgress?.(i + 1, texts.length);
}
return out;
}
/**
* Probe the Ollama daemon. Returns `{ ok: true, models: string[] }`
* if reachable, `{ ok: false, error }` otherwise. Used by
* `reasonix index` to fail fast with a friendly message before doing
* any chunking work.
*/
export async function probeOllama(
opts: { baseUrl?: string; signal?: AbortSignal } = {},
): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> {
const baseUrl = opts.baseUrl ?? process.env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL;
try {
const res = await fetch(`${baseUrl}/api/tags`, { signal: opts.signal });
if (!res.ok) return { ok: false, error: `Ollama returned ${res.status}` };
const json = (await res.json()) as { models?: Array<{ name?: string }> };
const models = (json.models ?? [])
.map((m) => m.name)
.filter((n): n is string => typeof n === "string");
return { ok: true, models };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, error: msg };
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Semantic-search user-facing string table, EN + ZH.
*
* Why locale-aware here when the rest of Reasonix is English-only:
* - These strings show up at high-friction moments (preflight prompts,
* `/semantic` status). Misunderstanding them costs the user real
* setup time. Other Reasonix surfaces (CLI --help, tool descriptions
* the model sees) stay English on purpose — translating tool
* descriptions would weaken the model's prompt-cache stability and
* break alignment with training-data-typical wording.
*
* Locale detection precedence: REASONIX_LANG env override → LANG /
* LC_ALL / LC_MESSAGES (Unix) → Intl.DateTimeFormat (Windows fallback)
* → "en". The override exists so users on systems with mis-configured
* LANG can force the language they actually read.
*/
export type Locale = "en" | "zh";
let cachedLocale: Locale | null = null;
export function detectLocale(): Locale {
if (cachedLocale) return cachedLocale;
const override = (process.env.REASONIX_LANG ?? "").toLowerCase();
if (override === "zh" || override === "en") {
cachedLocale = override;
return cachedLocale;
}
const env = process.env.LANG ?? process.env.LC_ALL ?? process.env.LC_MESSAGES ?? "";
if (/^zh[-_]/i.test(env)) {
cachedLocale = "zh";
return "zh";
}
try {
const sys = new Intl.DateTimeFormat().resolvedOptions().locale ?? "";
if (/^zh[-_]/i.test(sys)) {
cachedLocale = "zh";
return "zh";
}
} catch {
/* ignore — fall through to default */
}
cachedLocale = "en";
return "en";
}
/** Reset the cached locale. Tests use this; production never needs it. */
export function resetLocaleCache(): void {
cachedLocale = null;
}
/**
* Lookup a string by key; substitutes `{name}` placeholders from `vars`.
* Falls back to the English string when a translation is missing so a
* partial dictionary update never produces a literal "[missing]" in
* front of the user.
*/
export function t(key: keyof typeof EN, vars: Record<string, string | number> = {}): string {
const loc = detectLocale();
const dict = loc === "zh" ? ZH : EN;
const tpl = dict[key] ?? EN[key];
return tpl.replace(/\{(\w+)\}/g, (_m, name) => {
const v = vars[name];
return v === undefined ? `{${name}}` : String(v);
});
}
const EN = {
// ── preflight ─────────────────────────────────────────────────────
ollamaNotFound:
"✗ `ollama` not found on PATH.\n Install from https://ollama.com (one-time, ~150 MB), then retry.\n",
daemonNotReachableHint:
"✗ Ollama daemon not reachable. Run `ollama serve` and retry, or pass --yes to start it automatically.\n",
daemonStartConfirm: "Ollama daemon isn't running. Start `ollama serve` now?",
daemonAbortStart: "✗ aborted — start `ollama serve` yourself and retry.\n",
daemonStarting: "▸ starting `ollama serve`…\n",
daemonStartTimeout:
"✗ daemon didn't come up within 15s. Try `ollama serve` in a separate terminal and retry.\n",
daemonReady: "✓ daemon up{pid}\n",
modelNotPulledHint:
'✗ embedding model "{model}" not pulled. Run `ollama pull {model}` and retry, or pass --yes to pull it automatically.\n',
modelPullConfirm:
'Embedding model "{model}" isn\'t pulled yet. Pull it now? (~274 MB for nomic-embed-text)',
modelAbortPull: "✗ aborted — pull the model yourself and retry.\n",
modelPulling: "▸ pulling {model}…\n",
modelPullFailed: "✗ `ollama pull {model}` failed (exit {code}).\n",
modelPulled: "✓ {model} pulled\n",
// ── progress ─────────────────────────────────────────────────────
// The TTY-mode progress writer paints `<spinner> <status> <elapsed>s`
// every 120ms. The status itself comes from one of these keys based
// on the current phase. {files}, {done}, {total}, {pct} are
// substituted by the writer.
progressStarting: "starting…",
progressScan: "scanning project · {files} files",
progressEmbed: "embedding {done}/{total} chunks · {pct}%",
progressEmbedHeartbeat: " {done}/{total}\n",
progressScanLine: "scanning files…\n",
progressEmbedLine: "embedding {total} chunks across {files} files…\n",
// Final result line after a successful build.
indexSuccess:
"✓ indexed {scanned} files ({changed} changed, {added} new chunks, {removed} stale removed) in {seconds}s\n",
indexSuccessWithSkips:
"✓ indexed {scanned} files ({changed} changed, {added} new chunks, {removed} stale removed, {skipped} skipped due to embed errors) in {seconds}s\n",
indexNothingToDo: " (nothing to do — re-run with --rebuild to force a full rebuild)\n",
indexFailed: "✗ index failed: {msg}\n",
// ── /semantic slash ──────────────────────────────────────────────
slashHeader: "semantic_search status",
slashEnabled: "✓ enabled — index built, tool registered.",
slashEnabledDetail: " index size: {chunks} chunks across {files} files",
slashEnabledHowto: " the model will call semantic_search automatically when it fits.",
slashIndexMissing: "✗ no index built yet for this project.",
slashHowToBuild: " to enable, exit Reasonix and run in your shell:\n reasonix index",
slashOllamaMissing: " prerequisite: install Ollama from https://ollama.com",
slashDaemonDown:
" Ollama is installed but the daemon isn't running. start it with: ollama serve",
slashIndexInfo:
" what semantic_search does: cross-language code understanding via local embeddings.\n better than grep when you describe WHAT something does, not WHICH token to find.",
} as const;
const ZH: Partial<Record<keyof typeof EN, string>> = {
ollamaNotFound:
"✗ 未找到 `ollama`。\n 请访问 https://ollama.com 安装(一次性,约 150 MB),然后重试。\n",
daemonNotReachableHint:
"✗ Ollama 守护进程未启动。请运行 `ollama serve` 后重试,或加 --yes 让我自动启动。\n",
daemonStartConfirm: "Ollama 守护进程未运行。现在启动 `ollama serve` 吗?",
daemonAbortStart: "✗ 已取消——请自行运行 `ollama serve` 后重试。\n",
daemonStarting: "▸ 正在启动 `ollama serve`…\n",
daemonStartTimeout: "✗ 15 秒内守护进程未就绪。请在另一个终端运行 `ollama serve` 后重试。\n",
daemonReady: "✓ 守护进程已启动{pid}\n",
modelNotPulledHint:
'✗ 嵌入模型 "{model}" 未下载。请运行 `ollama pull {model}` 后重试,或加 --yes 让我自动下载。\n',
modelPullConfirm: '嵌入模型 "{model}" 还未下载。现在下载吗?(nomic-embed-text 约 274 MB)',
modelAbortPull: "✗ 已取消——请自行下载模型后重试。\n",
modelPulling: "▸ 正在下载 {model}…\n",
modelPullFailed: "✗ `ollama pull {model}` 失败(退出码 {code})。\n",
modelPulled: "✓ {model} 下载完成\n",
progressStarting: "正在启动…",
progressScan: "扫描项目 · 已扫描 {files} 个文件",
progressEmbed: "正在向量化 {done}/{total} 个片段 · {pct}%",
progressEmbedHeartbeat: " {done}/{total}\n",
progressScanLine: "正在扫描文件…\n",
progressEmbedLine: "正在向量化 {total} 个片段(涉及 {files} 个文件)…\n",
indexSuccess:
"✓ 已建立索引:扫描 {scanned} 个文件({changed} 个有变化,新增 {added} 个片段,移除 {removed} 个过期);耗时 {seconds}s\n",
indexSuccessWithSkips:
"✓ 已建立索引:扫描 {scanned} 个文件({changed} 个有变化,新增 {added} 个片段,移除 {removed} 个过期,跳过 {skipped} 个嵌入失败的片段);耗时 {seconds}s\n",
indexNothingToDo: " (没有变化——加 --rebuild 强制重建)\n",
indexFailed: "✗ 建立索引失败:{msg}\n",
slashHeader: "semantic_search 状态",
slashEnabled: "✓ 已启用——索引已建好,工具已注册。",
slashEnabledDetail: " 索引规模:{chunks} 个片段,{files} 个文件",
slashEnabledHowto: " 模型在合适的时候会自动调用 semantic_search。",
slashIndexMissing: "✗ 当前项目还没有索引。",
slashHowToBuild: " 启用方式:退出 Reasonix,在终端运行:\n reasonix index",
slashOllamaMissing: " 前置依赖:从 https://ollama.com 安装 Ollama",
slashDaemonDown: " 已装 Ollama 但守护进程未启动,请运行:ollama serve",
slashIndexInfo:
' semantic_search 用本地 embedding 做跨语言代码理解。\n 当你描述"做什么"而不是具体 token 时,比 grep 更好。',
};
+167
View File
@@ -0,0 +1,167 @@
/**
* Ollama preflight: detect, prompt, launch.
*
* `reasonix index` shouldn't punt the entire setup story to a wall-of-
* text README. The flow we want, in order:
*
* 1. Is the `ollama` binary on PATH?
* - No → print install URL + abort. (We don't run package
* managers on the user's behalf — too much blast radius.)
* - Yes → continue.
*
* 2. Is the daemon reachable on the configured URL?
* - No → ask "Start Ollama daemon now?" → spawn detached, poll
* until /api/tags responds (timeout 15s) → continue.
* - Yes → continue.
*
* 3. Is the embedding model pulled?
* - No → ask "Pull <model> now?" → run `ollama pull <model>`
* with streamed progress → continue.
* - Yes → continue.
*
* Each step is gated on user consent (TTY only) — non-interactive
* shells (CI, scripts) get a clear error message instead of a hang.
* Daemon spawn is detached + unref'd so the daemon survives past the
* Reasonix process; users own its lifecycle from then on.
*/
import { spawn, spawnSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { probeOllama } from "./embedding.js";
export interface OllamaStatus {
/** `ollama` binary present on PATH. */
binaryFound: boolean;
/** HTTP daemon reachable at the configured base URL. */
daemonRunning: boolean;
/** True if `<model>` (or `<model>:latest`) appears in `ollama list`. */
modelPulled: boolean;
/** Model the caller asked about — echoed for log clarity. */
modelName: string;
/** Models the daemon reported, for diagnostics. Empty when daemon down. */
installedModels: string[];
}
/**
* Best-effort PATH lookup for `ollama`. We use `which` / `where` over
* `process.env.PATH` parsing because OS shells (and Windows
* App-installer entries) handle resolution rules we'd otherwise have
* to mirror. Synchronous because this runs once at startup, not in a
* hot loop.
*/
export function findOllamaBinary(): string | null {
const cmd = process.platform === "win32" ? "where" : "which";
const out = spawnSync(cmd, ["ollama"], { encoding: "utf8" });
if (out.status !== 0) return null;
const first = out.stdout.split(/\r?\n/).find((l) => l.trim().length > 0);
return first ? first.trim() : null;
}
/**
* Composite status check used by the CLI. Calls `findOllamaBinary` +
* `probeOllama` and tag-matches `<model>` against the daemon's listed
* models. Treats `<model>` and `<model>:latest` as the same — Ollama
* appends `:latest` to plain pulls.
*/
export async function checkOllamaStatus(
modelName: string,
baseUrl?: string,
): Promise<OllamaStatus> {
const binary = findOllamaBinary();
const probe = await probeOllama({ baseUrl });
const installedModels = probe.ok ? probe.models : [];
const wanted = modelName.includes(":") ? modelName : `${modelName}:latest`;
const modelPulled = installedModels.some((m) => m === modelName || m === wanted);
return {
binaryFound: binary !== null,
daemonRunning: probe.ok,
modelPulled,
modelName,
installedModels,
};
}
/**
* Spawn `ollama serve` detached so it survives past our process.
* Polls /api/tags until it responds OK (or `timeoutMs` elapses);
* resolves with `ready: true` on success, `ready: false` on timeout
* so the caller can surface a sensible error without throwing.
*
* On Windows we set `windowsHide: true` to avoid a ghost cmd window
* popping up next to the user's terminal. Output is discarded —
* users who want daemon logs run `ollama serve` themselves.
*/
export async function startOllamaDaemon(
opts: { baseUrl?: string; timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<{ ready: boolean; pid: number | null }> {
const timeoutMs = opts.timeoutMs ?? 15_000;
const child = spawn("ollama", ["serve"], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
const pid = child.pid ?? null;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (opts.signal?.aborted) return { ready: false, pid };
const probe = await probeOllama({ baseUrl: opts.baseUrl, signal: opts.signal });
if (probe.ok) return { ready: true, pid };
await sleep(500);
}
return { ready: false, pid };
}
/**
* Run `ollama pull <model>` and stream output to the caller. Resolves
* with the exit code; non-zero means the pull failed (network down,
* disk full, model name typo) and the CLI should surface stderr.
*
* `onLine` is called per stdout/stderr line so the CLI can render its
* own progress bar instead of dumping ollama's TTY-aware output verbatim.
*/
export async function pullOllamaModel(
modelName: string,
opts: { onLine?: (line: string, stream: "stdout" | "stderr") => void; signal?: AbortSignal } = {},
): Promise<number> {
return new Promise<number>((resolve) => {
const child = spawn("ollama", ["pull", modelName], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (opts.signal) {
const onAbort = () => child.kill();
opts.signal.addEventListener("abort", onAbort, { once: true });
child.once("exit", () => opts.signal?.removeEventListener("abort", onAbort));
}
streamLines(child.stdout, (l) => opts.onLine?.(l, "stdout"));
streamLines(child.stderr, (l) => opts.onLine?.(l, "stderr"));
child.once("exit", (code) => resolve(code ?? -1));
child.once("error", () => resolve(-1));
});
}
/**
* Read newline-delimited output from a stream and invoke `cb` per
* line. Buffers partial lines so a chunk that splits mid-token
* doesn't fragment the output the user sees.
*/
function streamLines(stream: NodeJS.ReadableStream | null, cb: (line: string) => void): void {
if (!stream) return;
let buf = "";
stream.setEncoding("utf8");
stream.on("data", (chunk: string) => {
buf += chunk;
let nl = buf.indexOf("\n");
while (nl !== -1) {
const line = buf.slice(0, nl).replace(/\r$/, "");
buf = buf.slice(nl + 1);
if (line.length > 0) cb(line);
nl = buf.indexOf("\n");
}
});
stream.on("end", () => {
if (buf.length > 0) cb(buf.replace(/\r$/, ""));
});
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Shared Ollama preflight + readline confirm. Used by both the
* standalone `reasonix index` command and the code-mode bootstrap
* (`bootstrapSemanticSearchInCodeMode` in tool.ts) so users see one
* consistent setup story however they reach it.
*
* All user-facing text routes through the i18n table — Chinese
* locales get Chinese, everyone else gets English. See ./i18n.ts.
*/
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { t } from "./i18n.js";
import { checkOllamaStatus, pullOllamaModel, startOllamaDaemon } from "./ollama-launcher.js";
export interface PreflightOptions {
/** Embedding model to verify is pulled. */
model: string;
/** Override Ollama base URL. */
baseUrl?: string | undefined;
/** Allow interactive prompts (TTY only). When false, missing
* daemon / model causes a hard failure with a remediation hint. */
interactive: boolean;
/** Skip prompts and act on every offer. Implies non-interactive. */
yesToAll: boolean;
/** Optional log sink. Defaults to stderr writes. */
log?: (line: string) => void;
}
/**
* Walk through the Ollama setup story before any chunking happens.
* Returns true when ready to embed, false when something is missing
* we can't or shouldn't fix automatically. Prints actionable hints
* for every failure path so the user always knows what to do next.
*/
export async function ollamaPreflight(opts: PreflightOptions): Promise<boolean> {
const log = opts.log ?? ((line: string) => process.stderr.write(line));
const status = await checkOllamaStatus(opts.model, opts.baseUrl);
if (!status.binaryFound) {
log(t("ollamaNotFound"));
return false;
}
if (!status.daemonRunning) {
if (!opts.interactive && !opts.yesToAll) {
log(t("daemonNotReachableHint"));
return false;
}
const ok = opts.yesToAll || (await confirm(t("daemonStartConfirm"), true));
if (!ok) {
log(t("daemonAbortStart"));
return false;
}
log(t("daemonStarting"));
const started = await startOllamaDaemon({ baseUrl: opts.baseUrl, timeoutMs: 15_000 });
if (!started.ready) {
log(t("daemonStartTimeout"));
return false;
}
log(t("daemonReady", { pid: started.pid ? ` (pid ${started.pid})` : "" }));
}
// Re-check the pulled-models list after a fresh daemon start —
// the prior status snapshot was taken when the daemon was down.
const after = status.daemonRunning ? status : await checkOllamaStatus(opts.model, opts.baseUrl);
if (!after.modelPulled) {
if (!opts.interactive && !opts.yesToAll) {
log(t("modelNotPulledHint", { model: opts.model }));
return false;
}
const ok = opts.yesToAll || (await confirm(t("modelPullConfirm", { model: opts.model }), true));
if (!ok) {
log(t("modelAbortPull"));
return false;
}
log(t("modelPulling", { model: opts.model }));
// Strip ANSI CSI sequences from ollama's verbose pull output so
// our log stays readable. ESC built from char code so biome's
// noControlCharactersInRegex doesn't flag the literal.
const ESC = String.fromCharCode(0x1b);
const ANSI_CSI = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "g");
const code = await pullOllamaModel(opts.model, {
onLine: (line) => {
const cleaned = line.replace(ANSI_CSI, "").trim();
if (cleaned.length === 0) return;
log(` ${cleaned}\n`);
},
});
if (code !== 0) {
log(t("modelPullFailed", { model: opts.model, code }));
return false;
}
log(t("modelPulled", { model: opts.model }));
}
return true;
}
/**
* Yes/no readline prompt. `[Y/n]` or `[y/N]` rendered based on
* `defaultYes`; empty input takes the default. Closes its readline
* interface so the parent process exits cleanly.
*/
export async function confirm(question: string, defaultYes: boolean): Promise<boolean> {
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
const rl = createInterface({ input: stdin, output: stdout });
try {
const raw = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
if (raw === "") return defaultYes;
return raw === "y" || raw === "yes";
} finally {
rl.close();
}
}
+331
View File
@@ -0,0 +1,331 @@
/**
* JSONL persistence + in-memory cosine search for the semantic index.
*
* Layout choice — JSONL not SQLite, not a packed binary file:
* - Zero native deps (matches Reasonix's npm-only install story).
* - `cat .reasonix/index.jsonl | wc -l` answers "how big is my
* index" without code; `head` peeks at content; `git diff`
* reads if you ever commit it.
* - Append-only writes survive Ctrl+C without corruption — the
* same atomic-append discipline `~/.reasonix/sessions/` uses.
*
* Search is a linear cosine scan. For ≤10k chunks (typical mid-size
* project) one query is <5ms on a modern CPU because the inner loop
* runs over Float32Array which V8 keeps unboxed. Switching to HNSW
* is on the post-MVP list; doing it before linear scan hurts
* (faster than necessary, opaque debugging, native deps).
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import type { CodeChunk } from "./chunker.js";
export interface IndexEntry extends CodeChunk {
/** L2-normalized embedding so cosine reduces to a dot product. */
embedding: Float32Array;
/** mtime of the source file when this entry was indexed (ms epoch).
* Lets the builder skip unchanged files on incremental rebuilds. */
mtimeMs: number;
}
export interface SearchHit {
entry: IndexEntry;
score: number;
}
export interface IndexMeta {
version: number;
/** Embedding model the index was built with — invalidates when changed. */
model: string;
/** Embedding dimensionality — sanity check on append. */
dim: number;
/** ISO timestamp of last write. */
updatedAt: string;
}
/** Bumped when the JSONL line schema changes. Old indexes are rebuilt. */
export const STORE_VERSION = 1;
const META_FILE = "index.meta.json";
const DATA_FILE = "index.jsonl";
/**
* In-memory index. Owns the loaded entries + the directory paths,
* provides cosine search, and writes through to disk on every
* `add`. Each store instance is scoped to one project (one sandbox
* root in `reasonix code`).
*/
export class SemanticStore {
private entries: IndexEntry[] = [];
private byPath = new Map<string, IndexEntry[]>();
private dim = 0;
constructor(
/** Directory the index files live in (e.g. `<project>/.reasonix/`). */
public readonly indexDir: string,
/** Embedding model name written into meta and checked on load. */
public readonly model: string,
) {}
/** True when no entries are loaded — the index doesn't exist or is empty. */
get empty(): boolean {
return this.entries.length === 0;
}
/** Total number of indexed chunks. */
get size(): number {
return this.entries.length;
}
/** Read-only view, mostly for tests. */
get all(): readonly IndexEntry[] {
return this.entries;
}
/** Last-known mtime per indexed file (ms epoch) for incremental rebuilds. */
fileMtimes(): Map<string, number> {
const out = new Map<string, number>();
for (const [p, group] of this.byPath) {
// Every chunk from a given file shares the same mtime; first
// entry's value is authoritative.
const first = group[0];
if (first) out.set(p, first.mtimeMs);
}
return out;
}
/** Append entries to in-memory state and to disk. Re-indexes the
* `byPath` map. Caller is responsible for L2-normalizing each
* embedding before calling — the search hot path assumes unit vectors. */
async add(entries: readonly IndexEntry[]): Promise<void> {
if (entries.length === 0) return;
if (this.dim === 0) this.dim = entries[0]!.embedding.length;
const lines: string[] = [];
for (const e of entries) {
if (e.embedding.length !== this.dim) {
throw new Error(
`embedding dim mismatch: expected ${this.dim}, got ${e.embedding.length} for ${e.path}:${e.startLine}`,
);
}
this.entries.push(e);
const list = this.byPath.get(e.path);
if (list) list.push(e);
else this.byPath.set(e.path, [e]);
lines.push(serializeEntry(e));
}
await fs.mkdir(this.indexDir, { recursive: true });
await fs.appendFile(path.join(this.indexDir, DATA_FILE), `${lines.join("\n")}\n`, "utf8");
await this.writeMeta();
}
/**
* Drop every entry whose `path` is in `paths`. Used by incremental
* rebuild: when a file's mtime changes, the existing entries for
* it are evicted before re-chunking + re-embedding. Implementation
* rewrites the JSONL — append-only is fine for adds, but deletes
* need a compaction pass.
*/
async remove(paths: readonly string[]): Promise<number> {
if (paths.length === 0) return 0;
const drop = new Set(paths);
const before = this.entries.length;
this.entries = this.entries.filter((e) => !drop.has(e.path));
for (const p of paths) this.byPath.delete(p);
const removed = before - this.entries.length;
if (removed > 0) await this.flush();
return removed;
}
/**
* Top-K cosine search. `query` MUST already be L2-normalized — the
* caller embeds + normalizes once per query. Filtering hits by
* minimum score (`minScore`) is optional; tune via UI to suppress
* weakly relevant snippets that distract the model.
*/
search(query: Float32Array, topK = 8, minScore = 0): SearchHit[] {
if (this.entries.length === 0) return [];
if (query.length !== this.dim && this.dim !== 0) {
throw new Error(`query dim ${query.length} ≠ index dim ${this.dim}`);
}
// Use a small max-heap-ish structure: keep `topK + 1` entries,
// drop smallest each insert. For typical topK (≤16) this beats
// sorting the full list at small extra constant cost.
const heap: SearchHit[] = [];
for (const entry of this.entries) {
const score = dot(query, entry.embedding);
if (score < minScore) continue;
if (heap.length < topK) {
heap.push({ entry, score });
if (heap.length === topK) heap.sort((a, b) => a.score - b.score);
} else if (score > heap[0]!.score) {
heap[0] = { entry, score };
// Maintain partial order for the smallest at index 0.
for (let i = 0; i < heap.length - 1; i++) {
if (heap[i]!.score > heap[i + 1]!.score) {
const tmp = heap[i]!;
heap[i] = heap[i + 1]!;
heap[i + 1] = tmp;
}
}
}
}
return heap.sort((a, b) => b.score - a.score);
}
/**
* Rewrite the JSONL on disk with the current in-memory state. Used
* after `remove` and from `flush`. We write to a temp file and
* rename so a Ctrl+C mid-write never leaves the index half-empty.
*/
private async flush(): Promise<void> {
await fs.mkdir(this.indexDir, { recursive: true });
const tmp = path.join(this.indexDir, `${DATA_FILE}.tmp`);
const final = path.join(this.indexDir, DATA_FILE);
const lines = this.entries.map(serializeEntry).join("\n");
await fs.writeFile(tmp, lines.length > 0 ? `${lines}\n` : "", "utf8");
await fs.rename(tmp, final);
await this.writeMeta();
}
private async writeMeta(): Promise<void> {
const meta: IndexMeta = {
version: STORE_VERSION,
model: this.model,
dim: this.dim,
updatedAt: new Date().toISOString(),
};
await fs.writeFile(
path.join(this.indexDir, META_FILE),
`${JSON.stringify(meta, null, 2)}\n`,
"utf8",
);
}
/** Drop everything from disk + memory. Used by `--rebuild`. */
async wipe(): Promise<void> {
this.entries = [];
this.byPath.clear();
this.dim = 0;
await fs.rm(path.join(this.indexDir, DATA_FILE), { force: true });
await fs.rm(path.join(this.indexDir, META_FILE), { force: true });
}
}
/**
* Open an existing index from disk, or return an empty store if none
* exists. Throws when the on-disk model name disagrees with the
* caller's — the embeddings would be incomparable, so the caller
* needs to wipe + rebuild deliberately.
*/
export async function openStore(indexDir: string, model: string): Promise<SemanticStore> {
const store = new SemanticStore(indexDir, model);
const dataPath = path.join(indexDir, DATA_FILE);
const metaPath = path.join(indexDir, META_FILE);
let meta: IndexMeta | null = null;
try {
const raw = await fs.readFile(metaPath, "utf8");
meta = JSON.parse(raw) as IndexMeta;
} catch {
/* no meta yet — fresh store */
}
if (meta) {
if (meta.version !== STORE_VERSION) {
throw new Error(
`Index format version ${meta.version} does not match current ${STORE_VERSION}. Run \`reasonix index --rebuild\`.`,
);
}
if (meta.model !== model) {
throw new Error(
`Index was built with model "${meta.model}" but current is "${model}". Run \`reasonix index --rebuild\`.`,
);
}
}
let raw: string;
try {
raw = await fs.readFile(dataPath, "utf8");
} catch {
return store;
}
for (const line of raw.split("\n")) {
if (line.length === 0) continue;
try {
const entry = deserializeEntry(line);
(store as unknown as { dim: number }).dim = entry.embedding.length;
// Bypass `add` to skip the per-line disk write on load.
(store as unknown as { entries: IndexEntry[] }).entries.push(entry);
const map = (store as unknown as { byPath: Map<string, IndexEntry[]> }).byPath;
const list = map.get(entry.path);
if (list) list.push(entry);
else map.set(entry.path, [entry]);
} catch {
// Malformed line — drop it but keep going. Same tolerance as
// session.ts shows for partially-flushed JSONL.
}
}
return store;
}
/**
* L2-normalize a vector in place + return it for chaining. Cosine
* similarity reduces to a dot product when both operands are unit
* vectors, which is what the search hot path assumes.
*/
export function normalize(v: Float32Array): Float32Array {
let sum = 0;
for (let i = 0; i < v.length; i++) sum += v[i]! * v[i]!;
const inv = sum > 0 ? 1 / Math.sqrt(sum) : 0;
for (let i = 0; i < v.length; i++) v[i] = v[i]! * inv;
return v;
}
/** Pure dot product of two same-length Float32Arrays. */
function dot(a: Float32Array, b: Float32Array): number {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!;
return s;
}
/**
* Wire format — JSON object per line. Embedding is encoded as
* base64 over the underlying bytes (Float32 → Uint8 → base64) which
* is ~33% smaller than the JSON-array form and round-trips losslessly.
*/
function serializeEntry(e: IndexEntry): string {
const buf = Buffer.from(e.embedding.buffer, e.embedding.byteOffset, e.embedding.byteLength);
return JSON.stringify({
p: e.path,
s: e.startLine,
e: e.endLine,
m: e.mtimeMs,
t: e.text,
v: buf.toString("base64"),
});
}
function deserializeEntry(line: string): IndexEntry {
const parsed = JSON.parse(line) as {
p: string;
s: number;
e: number;
m: number;
t: string;
v: string;
};
const bytes = Buffer.from(parsed.v, "base64");
// The float32 view shares memory with the buffer; copy so the
// entry is independent of the parser's transient buffer.
const f32 = new Float32Array(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
);
return {
path: parsed.p,
startLine: parsed.s,
endLine: parsed.e,
mtimeMs: parsed.m,
text: parsed.t,
embedding: f32,
};
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Register `semantic_search` on a ToolRegistry. The tool is gated on
* an existing on-disk index — calling `registerSemanticSearchTool`
* with no index is a no-op so the model never sees a tool it can't
* actually use.
*
* The tool description teaches the model when this beats grep:
* - grep: known token / exact identifier / structural pattern
* - semantic_search: paraphrased intent ("where do we handle auth
* failures") / cross-language concept lookup / discovery in
* unfamiliar code
* Without this nudge the model defaults to grep for everything and
* misses retrieval-tool wins on the questions semantic_search is
* actually built for.
*/
import type { ToolRegistry } from "../../tools.js";
import { indexExists, querySemantic } from "./builder.js";
import type { EmbedOptions } from "./embedding.js";
import type { SearchHit } from "./store.js";
export interface SemanticToolOptions extends EmbedOptions {
/** Project root (sandbox dir). Index lives at `<root>/.reasonix/semantic/`. */
root: string;
/** Default top-K when the model omits it. Default 8. */
defaultTopK?: number;
/** Default min score. Default 0.3. */
defaultMinScore?: number;
}
/**
* Register the tool when an index is present. Returns `true` if
* registered. Callers can warn the user when this returns `false`
* so they know to run `reasonix index`.
*/
export async function registerSemanticSearchTool(
registry: ToolRegistry,
opts: SemanticToolOptions,
): Promise<boolean> {
if (!(await indexExists(opts.root))) return false;
const defaultTopK = opts.defaultTopK ?? 8;
const defaultMinScore = opts.defaultMinScore ?? 0.3;
registry.register({
name: "semantic_search",
description:
"FIRST CHOICE for descriptive queries. Use this BEFORE search_content (grep) when the user describes WHAT code does ('where do we handle X', 'which file owns Y', 'how does Z work', 'find the logic that …'). Returns ranked snippets ordered by semantic relevance — finds the right file even when your description shares no words with the code. Falls back to search_content / search_files only for: exact identifiers, regex patterns, or counting occurrences of a known token. If your first instinct is grep on a paraphrased question, you are wrong — try semantic_search first.",
readOnly: true,
parameters: {
type: "object",
properties: {
query: {
type: "string",
description:
"Natural-language description, phrased as a question or noun phrase: 'where do we validate the session cookie?' / 'retry backoff logic' / 'code that prevents user changes from immediately landing on disk'. Do NOT pass exact identifiers — those are search_content's job.",
},
topK: {
type: "integer",
description: `Number of snippets to return (1..16). Default ${defaultTopK}.`,
},
minScore: {
type: "number",
description: `Drop snippets with cosine score below this (0..1). Default ${defaultMinScore}. Raise for stricter matches; lower if the index is small.`,
},
},
required: ["query"],
},
fn: async (args: { query: string; topK?: number; minScore?: number }, ctx) => {
const hits = await querySemantic(opts.root, args.query, {
topK: args.topK ?? defaultTopK,
minScore: args.minScore ?? defaultMinScore,
baseUrl: opts.baseUrl,
model: opts.model,
signal: ctx?.signal,
});
if (hits === null) {
return "No semantic index found for this project. Run `reasonix index` to build one.";
}
if (hits.length === 0) {
return `query: ${args.query}\n\nno matches above the score threshold (${args.minScore ?? defaultMinScore}).`;
}
return formatHits(args.query, hits);
},
});
return true;
}
/**
* Render hits the same way `web_search` formats its results — header
* with the query, numbered entries with file:line citation + score
* + a snippet preview. Keeps tool-output style consistent so users
* who learn to read one read both.
*/
export function formatHits(query: string, hits: readonly SearchHit[]): string {
const lines: string[] = [`query: ${query}`, `\nresults (${hits.length}):`];
hits.forEach((h, i) => {
const { entry, score } = h;
lines.push(
`\n${i + 1}. ${entry.path}:${entry.startLine}-${entry.endLine} (score ${score.toFixed(3)})`,
);
// Cap each snippet so a 60-line chunk doesn't dominate the
// model's context. The full chunk is still discoverable via
// read_file once the model picks the most relevant hit.
const preview = entry.text.split("\n").slice(0, 8).join("\n");
lines.push(indentBlock(preview, " "));
if (entry.text.split("\n").length > 8) {
lines.push(
` …(${entry.text.split("\n").length - 8} more lines — read_file ${entry.path}:${entry.startLine} for the full chunk)`,
);
}
});
return lines.join("\n");
}
function indentBlock(text: string, prefix: string): string {
return text
.split("\n")
.map((l) => prefix + l)
.join("\n");
}
/**
* `reasonix code` startup bootstrap. Deliberately silent and
* non-prompting:
*
* - Index exists → register the tool. The model can use it this
* session.
* - No index → skip. NO probe of Ollama, NO setup question.
*
* Why no startup prompt: even a one-time Y/n at launch is intrusive
* for users who just want to start coding. Discovery happens via
* `/semantic` slash inside the TUI when the user is curious, plus a
* single dim line in the welcome banner. Users explicitly opt in by
* running `reasonix index` in their shell — the established Reasonix
* pattern (see `/setup`, `/update`).
*/
export async function bootstrapSemanticSearchInCodeMode(
registry: ToolRegistry,
rootDir: string,
opts: EmbedOptions = {},
): Promise<{ enabled: boolean }> {
if (await indexExists(rootDir)) {
await registerSemanticSearchTool(registry, { ...opts, root: rootDir });
return { enabled: true };
}
return { enabled: false };
}
+28 -2
View File
@@ -294,8 +294,12 @@ export class CacheFirstLoop {
* tool call is one array length check.
*/
hooks: ResolvedHook[];
/** `cwd` reported to hook stdin. Resolved once at construction. */
readonly hookCwd: string;
/**
* `cwd` reported to hook stdin. Mutable so `/cwd` can switch the
* working directory mid-session — the App keeps it in sync with
* the same currentRootDir that drives tool re-registration.
*/
hookCwd: string;
/** Number of messages that were pre-loaded from the session file. */
readonly resumedMessageCount: number;
@@ -873,7 +877,18 @@ export class CacheFirstLoop {
// Fresh controller for this turn: the prior step's signal has
// already fired (or stayed clean); either way we don't want its
// state to bleed into the new turn.
//
// Edge case — `loop.abort()` may have been called BEFORE step()
// ran (race: caller fires abort during async setup, but step()
// hadn't been awaited yet). Naively reassigning _turnAbort would
// silently drop that abort. Forward the prior aborted state into
// the fresh controller so the iter-0 check still bails out. This
// is load-bearing for subagents: the parent's onParentAbort
// listener calls childLoop.abort(), which can fire before
// childLoop.step() has reached the `for await` line below.
const carryAbort = this._turnAbort.signal.aborted;
this._turnAbort = new AbortController();
if (carryAbort) this._turnAbort.abort();
const signal = this._turnAbort.signal;
if (armedConsumed) {
yield {
@@ -926,6 +941,17 @@ export class CacheFirstLoop {
};
this.autoCompactToolResultsOnTurnEnd();
yield { turn: this._turn, role: "done", content: stoppedMsg };
// Reset to a fresh, non-aborted controller before returning.
// Without this the carry-abort logic above sees the still-
// aborted controller on the NEXT step() entry and immediately
// re-aborts at iter 0, locking the session: every subsequent
// user message produces "stopped without producing a summary"
// before any work happens. A user-initiated Esc is a discrete
// event tied to ONE turn; it must not bleed into the next.
// (The race scenario the carry-abort handles — abort fired in
// the async window before step() entry — still works: a fresh
// abort() between turns aborts the new controller below.)
this._turnAbort = new AbortController();
return;
}
// Bridge the silence between the PREVIOUS iter's tool result and
+30 -9
View File
@@ -2,11 +2,13 @@
* Parse the `--mcp` CLI argument into a transport-tagged spec.
*
* Accepted forms:
* "name=command args..." → stdio, namespaced (tools prefixed with `name_`)
* "command args..." → stdio, anonymous
* "name=https://host/sse" → SSE, namespaced
* "https://host/sse" → SSE, anonymous
* ("http://" is also honored — useful for local dev servers.)
* "name=command args..." → stdio, namespaced (tools prefixed with `name_`)
* "command args..." → stdio, anonymous
* "name=https://host/sse" → HTTP+SSE (2024-11-05), namespaced
* "https://host/sse" → HTTP+SSE (2024-11-05), anonymous
* "name=streamable+https://host/mcp" → Streamable HTTP (2025-03-26), namespaced
* "streamable+https://host/mcp" → Streamable HTTP (2025-03-26), anonymous
* ("http://" / "streamable+http://" also honored — useful for local dev.)
*
* The identifier regex before `=` is deliberately narrow
* (`[a-zA-Z_][a-zA-Z0-9_]*`) so Windows drive letters ("C:\\...") and
@@ -15,9 +17,15 @@
* with `foo=...` as a bare command, they can wrap it in quotes inside the
* shell command string.
*
* Transport is selected solely by whether the body begins with `http://`
* or `https://`. Anything else is stdio — including ws:// (unsupported)
* which will surface later as a spawn error, keeping the rule local.
* Transport selection:
* - body starts with `streamable+http(s)://` → Streamable HTTP. The
* `streamable+` prefix is stripped from the URL we hand the transport.
* - body starts with `http(s)://` → HTTP+SSE (2024-11-05).
* Default for plain http URLs to preserve back-compat with users who
* already have `--mcp https://...` config entries pointed at SSE
* servers; opt into Streamable HTTP explicitly.
* - anything else → stdio (including ws://,
* which will surface later as a spawn error).
*/
import { shellSplit } from "./shell-split.js";
@@ -39,10 +47,18 @@ export interface SseMcpSpec {
url: string;
}
export type McpSpec = StdioMcpSpec | SseMcpSpec;
export interface StreamableHttpMcpSpec {
transport: "streamable-http";
name: string | null;
/** Fully qualified Streamable HTTP endpoint URL (no `streamable+` prefix). */
url: string;
}
export type McpSpec = StdioMcpSpec | SseMcpSpec | StreamableHttpMcpSpec;
const NAME_PREFIX = /^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/;
const HTTP_URL = /^https?:\/\//i;
const STREAMABLE_PREFIX = /^streamable\+(https?:\/\/.+)$/i;
export function parseMcpSpec(input: string): McpSpec {
const trimmed = input.trim();
@@ -58,6 +74,11 @@ export function parseMcpSpec(input: string): McpSpec {
throw new Error(`MCP spec has name but no command: ${input}`);
}
const streamMatch = STREAMABLE_PREFIX.exec(body);
if (streamMatch) {
return { transport: "streamable-http", name, url: streamMatch[1]! };
}
if (HTTP_URL.test(body)) {
return { transport: "sse", name, url: body };
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Streamable HTTP transport for MCP (spec version 2025-03-26).
*
* Wire shape (single endpoint, no separate POST URL handshake):
*
* 1. Client POSTs each outgoing JSON-RPC frame to the endpoint with
* `Accept: application/json, text/event-stream`. The server picks
* ONE of three responses:
* a. `202 Accepted`, no body → notification or response
* was accepted; nothing more to deliver.
* b. `200 OK`, `Content-Type: application/json` → body is a
* single JSON-RPC response (or batch). Connection closes.
* c. `200 OK`, `Content-Type: text/event-stream` → an SSE
* stream of `event: message` frames carrying responses,
* server-initiated requests, and notifications. Stream may
* close after the matching response or stay open longer.
* 2. The server may include `Mcp-Session-Id: <opaque>` on the response
* to `initialize`. Client echoes that header on every subsequent
* request. A 404 on a request with a session id means the session
* expired — caller must reinitialize.
*
* Compared to 2024-11-05 HTTP+SSE:
* - No two-endpoint dance (no `event: endpoint` handshake).
* - Replies arrive on the POST response, not on a separate GET stream.
* - Session continuity is explicit (`Mcp-Session-Id`), not implicit.
*
* Not yet implemented in this transport (acceptable for v1):
* - Long-lived GET stream for unsolicited server-initiated frames
* (sampling requests, etc.). Most MCP servers we care about today
* don't issue server-initiated requests, and POST-only handles
* full request/response/notification traffic. Add when a real
* server we're integrating against needs it.
* - Resumability via `Last-Event-ID` on reconnect.
*/
import { createParser } from "eventsource-parser";
import type { McpTransport } from "./stdio.js";
import type { JsonRpcMessage } from "./types.js";
export interface StreamableHttpTransportOptions {
/** Streamable HTTP endpoint URL, e.g. `https://mcp.example.com/mcp`. */
url: string;
/** Extra headers sent on every request (e.g. `Authorization`). */
headers?: Record<string, string>;
}
const SESSION_HEADER = "mcp-session-id";
export class StreamableHttpTransport implements McpTransport {
private readonly url: string;
private readonly extraHeaders: Record<string, string>;
private readonly queue: JsonRpcMessage[] = [];
private readonly waiters: Array<(m: JsonRpcMessage | null) => void> = [];
private readonly controller = new AbortController();
/** Session id minted by server on (typically) the initialize response. */
private sessionId: string | null = null;
private closed = false;
/** Background SSE read-loops kicked off by send(); awaited on close(). */
private readonly streams = new Set<Promise<void>>();
constructor(opts: StreamableHttpTransportOptions) {
this.url = opts.url;
this.extraHeaders = opts.headers ?? {};
}
async send(message: JsonRpcMessage): Promise<void> {
if (this.closed) throw new Error("MCP Streamable HTTP transport is closed");
const headers: Record<string, string> = {
"content-type": "application/json",
// Both accepted — server picks. application/json first signals a
// mild preference for the simpler shape when the response is a
// single message.
accept: "application/json, text/event-stream",
...this.extraHeaders,
};
if (this.sessionId !== null) headers["mcp-session-id"] = this.sessionId;
let res: Response;
try {
res = await fetch(this.url, {
method: "POST",
headers,
body: JSON.stringify(message),
signal: this.controller.signal,
});
} catch (err) {
throw new Error(`MCP Streamable HTTP POST ${this.url} failed: ${(err as Error).message}`);
}
// Capture session id the first time the server hands one out.
const serverSessionId = res.headers.get(SESSION_HEADER);
if (serverSessionId && this.sessionId === null) {
this.sessionId = serverSessionId;
}
if (res.status === 404 && this.sessionId !== null) {
// Session expired / unknown to the server. Surface as an error so
// McpClient can recreate; drain the body so the socket goes back
// to the pool.
await res.body?.cancel().catch(() => undefined);
throw new Error(
`MCP Streamable HTTP session expired (server returned 404 with Mcp-Session-Id "${this.sessionId}"). Reinitialize the client.`,
);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`MCP Streamable HTTP POST ${this.url} → ${res.status} ${res.statusText}${body ? `: ${body}` : ""}`,
);
}
// 202 Accepted: request was a notification or pure ack — no body.
if (res.status === 202) {
await res.body?.cancel().catch(() => undefined);
return;
}
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
if (ct.includes("application/json")) {
let parsed: unknown;
try {
parsed = await res.json();
} catch (err) {
throw new Error(`MCP Streamable HTTP body wasn't valid JSON: ${(err as Error).message}`);
}
if (Array.isArray(parsed)) {
for (const item of parsed) this.pushMessage(item as JsonRpcMessage);
} else {
this.pushMessage(parsed as JsonRpcMessage);
}
return;
}
if (ct.includes("text/event-stream")) {
// Stream may carry multiple events (progress notifications +
// the eventual response). Read it concurrently with subsequent
// sends — return as soon as the stream is wired so callers can
// pipeline more requests.
if (!res.body) {
throw new Error("MCP Streamable HTTP SSE response had no body");
}
const stream = this.consumeStream(res.body as AsyncIterable<Uint8Array>);
this.streams.add(stream);
stream.finally(() => this.streams.delete(stream));
return;
}
// Unknown content type — drain and treat as a no-op rather than
// hanging. Servers that want to extend the protocol should not
// wedge older clients with an unexpected MIME.
await res.body?.cancel().catch(() => undefined);
}
async *messages(): AsyncIterableIterator<JsonRpcMessage> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
continue;
}
if (this.closed) return;
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
this.waiters.push(resolve);
});
if (next === null) return;
yield next;
}
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
while (this.waiters.length > 0) this.waiters.shift()!(null);
try {
this.controller.abort();
} catch {
/* already aborted */
}
// Wait for any in-flight SSE streams to wind down so a subsequent
// process.exit() doesn't trip on a hanging socket. Cap at "done";
// controller.abort() above unblocks them.
await Promise.allSettled(Array.from(this.streams));
}
/** Visible for tests — confirm session header round-trip. */
getSessionId(): string | null {
return this.sessionId;
}
// ---------- internals ----------
private async consumeStream(body: AsyncIterable<Uint8Array>): Promise<void> {
const parser = createParser({
onEvent: (ev) => {
// Per spec, server-side events use the `message` event type
// (default if `event:` line is missing). Other event types
// (server pings, custom extensions) we silently ignore.
const type = ev.event ?? "message";
if (type !== "message") return;
try {
const parsed = JSON.parse(ev.data) as JsonRpcMessage;
this.pushMessage(parsed);
} catch {
/* malformed JSON — drop, mirror SSE behavior */
}
},
});
const decoder = new TextDecoder();
try {
for await (const chunk of body) {
if (this.closed) break;
parser.feed(decoder.decode(chunk, { stream: true }));
}
} catch (err) {
if (!this.closed) {
this.pushMessage({
jsonrpc: "2.0",
id: null,
error: {
code: -32000,
message: `Streamable HTTP stream error: ${(err as Error).message}`,
},
});
}
}
}
private pushMessage(msg: JsonRpcMessage): void {
const waiter = this.waiters.shift();
if (waiter) waiter(msg);
else this.queue.push(msg);
}
}
+73 -7
View File
@@ -83,7 +83,7 @@ const DEFAULT_MAX_OUTPUT_CHARS = 32_000;
* is "exit 1 with output." Nothing that can rewrite state, escalate,
* or touch the network.
*/
const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
export const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
// Repo inspection
"git status",
"git diff",
@@ -328,7 +328,16 @@ export async function runCommand(
reject(err);
return;
}
let buf = "";
// Collect raw Buffer chunks rather than decoding incrementally —
// a multi-byte sequence can land split across chunks, and a naïve
// chunk.toString() corrupts it before the second half arrives.
// We decode once at close time, where smartDecodeOutput can also
// sniff non-UTF-8 codepages cleanly. The byte cap mirrors the
// prior char cap (2× maxChars worth) so a chatty process can't
// OOM us.
const chunks: Buffer[] = [];
let totalBytes = 0;
const byteCap = maxChars * 2 * 4; // worst-case 4 bytes/char for utf-8/gbk
let timedOut = false;
const killTimer = setTimeout(() => {
timedOut = true;
@@ -338,11 +347,16 @@ export async function runCommand(
opts.signal?.addEventListener("abort", onAbort, { once: true });
const onData = (chunk: Buffer | string) => {
buf += chunk.toString();
// Soft cap: we let the process keep running (killing early could
// hide a real failure), but we stop growing the buffer past 2×
// the cap so a chatty test can't OOM us.
if (buf.length > maxChars * 2) buf = `${buf.slice(0, maxChars * 2)}`;
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
if (totalBytes >= byteCap) return;
const remaining = byteCap - totalBytes;
if (b.length > remaining) {
chunks.push(b.subarray(0, remaining));
totalBytes = byteCap;
} else {
chunks.push(b);
totalBytes += b.length;
}
};
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
@@ -354,6 +368,8 @@ export async function runCommand(
child.on("close", (code) => {
clearTimeout(killTimer);
opts.signal?.removeEventListener("abort", onAbort);
const merged = Buffer.concat(chunks);
const buf = smartDecodeOutput(merged);
const output =
buf.length > maxChars
? `${buf.slice(0, maxChars)}\n\n[… truncated ${buf.length - maxChars} chars …]`
@@ -363,6 +379,56 @@ export async function runCommand(
});
}
/**
* Decode a child-process output buffer with codepage fallback. The
* default UTF-8 path handles 99% of Linux / Mac and any process we
* already coerced to UTF-8 (Python via PYTHONIOENCODING, PowerShell
* via the OutputEncoding prelude, cmd.exe builtins via chcp 65001).
*
* On Windows, two cases still leak non-UTF-8 bytes through that
* pipeline:
*
* 1. `cmd.exe`'s OWN error messages (e.g. "'sed' is not recognized
* as an internal or external command") come from a localized
* resource DLL and ignore the active code page, so chcp 65001
* doesn't help. On Chinese Windows the bytes are CP936/GBK.
*
* 2. Native Windows EXEs that hardcode the system locale for stderr
* (older sed/grep ports, MS toolchain prompts).
*
* We try UTF-8 strictly; if the bytes don't form valid UTF-8 we fall
* back to GBK on Windows (covers Simplified Chinese and most CJK
* Windows installs because GBK is a strict superset of ASCII and
* decodes Latin text identically). Better than mojibake; not a full
* locale-detector, but matches where the actual bug reports come
* from.
*
* Exported for tests.
*/
export function smartDecodeOutput(buf: Buffer): string {
if (buf.length === 0) return "";
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
} catch {
// Fall through to platform-specific fallback.
}
if (process.platform === "win32") {
try {
// TextDecoder supports gbk / gb18030 in Node 18+ via the WHATWG
// Encoding spec. gb18030 is the modern superset; falling back
// to it covers GBK byte sequences plus the rare 4-byte CJK
// characters that appear in newer system messages.
return new TextDecoder("gb18030").decode(buf);
} catch {
// Decoder unavailable in this build — fall through.
}
}
// Last resort: lossy UTF-8 with replacement chars. The model still
// gets "something happened" with the structural exit-code marker
// intact, which is more useful than throwing away the entire output.
return buf.toString("utf8");
}
/**
* Test/override hooks for {@link resolveExecutable}. Omitting any field
* falls through to the real process globals — the runtime call path
+31 -2
View File
@@ -272,8 +272,25 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
stream: false,
});
// Wire parent-abort → child-abort. Two pitfalls we have to handle:
//
// 1. `addEventListener("abort", ...)` does NOT fire for a signal
// that's already aborted (the abort event has already been
// dispatched once and `once: true` is moot). If the parent
// aborted between dispatch entry and our listener attach,
// the listener stays silent forever and the child runs free.
// → Check `.aborted` synchronously and forward immediately.
//
// 2. childLoop.step() reassigns its internal _turnAbort at the
// top of step(). loop.ts forwards prior aborted state into
// the fresh controller, so abort() called BEFORE step() runs
// still kills the new step at iter 0.
const onParentAbort = () => childLoop.abort();
opts.parentSignal?.addEventListener("abort", onParentAbort, { once: true });
if (opts.parentSignal?.aborted) {
childLoop.abort();
} else {
opts.parentSignal?.addEventListener("abort", onParentAbort, { once: true });
}
let final = "";
let errorMessage: string | undefined;
@@ -292,7 +309,19 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
});
}
if (ev.role === "assistant_final") {
final = ev.content ?? "";
// `forcedSummary: true` is the loop's signal that this isn't
// a real model answer — it's an abort placeholder, a budget-
// exhaustion summary, or a context-guard fallback. Treating
// them as success means parent agents would happily render
// "[aborted by user (Esc) — no summary produced.]" as the
// subagent's actual answer. Surface it as an error instead so
// `success: false` reaches the caller and `/skill` doesn't
// pretend the run completed normally.
if (ev.forcedSummary) {
errorMessage = ev.content?.trim() || "subagent ended without producing an answer";
} else {
final = ev.content ?? "";
}
}
if (ev.role === "error") {
errorMessage = ev.error ?? "subagent error";
+99
View File
@@ -0,0 +1,99 @@
/**
* `change_workspace` — model-callable workspace switching, gated on
* an explicit user confirmation in the TUI.
*
* The tool function itself never performs the switch. It validates
* the requested path and throws {@link WorkspaceConfirmationError},
* which the loop serializes into the tool result. App.tsx detects the
* marker, mounts a confirmation modal, and on approval drives the
* actual `setCwd` callback (the same one the `/cwd` slash uses).
*
* Why this shape (mirrors `run_command`):
* - The tool fn is pure / synchronous — no React or App-state
* coupling lives in tools/.
* - The model gets a clear "stop and wait" message in the tool
* result, which prevents a chain of switches before the user
* has had a chance to confirm even one.
* - Approval / denial flow back through a synthetic user message,
* so the model sees outcome on its next turn.
*
* Why no `always_allow`: workspace switches are per-target by nature
* (each target is a different project root with different secrets,
* different .gitignore, different memory); there's no useful "I trust
* the model to switch to ANY directory it picks" allowlist semantics.
* The modal offers Switch / Deny only.
*/
import { existsSync, statSync } from "node:fs";
import * as pathMod from "node:path";
import type { ToolRegistry } from "../tools.js";
export class WorkspaceConfirmationError extends Error {
readonly path: string;
constructor(path: string) {
super(
`change_workspace: switching to "${path}" needs the user's approval before it takes effect. STOP calling tools now — the TUI has already prompted the user to press Enter (switch) or Esc (deny). Wait for their next message; it will either confirm the switch (and your subsequent file/shell tools will resolve against the new root) or tell you to continue without changing directories.`,
);
this.name = "WorkspaceConfirmationError";
this.path = path;
}
}
export interface ChangeWorkspaceArgs {
path: string;
}
/**
* Register `change_workspace` on `registry`. The tool always throws
* `WorkspaceConfirmationError(absolutePath)` after path validation —
* the actual swap happens in App.tsx when the user approves the
* modal. Path validation matches `/cwd`'s: must exist, must be a
* directory, supports `~` expansion and relative paths (resolved
* against `process.cwd()` at call time, NOT the session root, so
* a model emitting `~/projects/foo` lands where the user expects).
*/
export function registerWorkspaceTool(registry: ToolRegistry): ToolRegistry {
registry.register({
name: "change_workspace",
description:
"Switch the session's working directory to a different project root. Re-registers filesystem / shell / memory tools against the new path so subsequent file reads, edits, and run_command calls all land there. EVERY switch requires explicit user approval via a modal — do NOT batch switches or chain a switch with subsequent tool calls before the user has confirmed. Use ONLY when the user explicitly asked to change directory or open a different project; never use to 'preview' a sibling repo. MCP servers stay anchored to the original launch root (their child processes can't be reconnected mid-session); the modal warns the user about this.",
parameters: {
type: "object",
required: ["path"],
properties: {
path: {
type: "string",
description:
"Target directory. Absolute paths land verbatim. Leading `~` expands to the user's home. Relative paths resolve against the user's launch cwd (not the current session root, so paths the user typed in chat resolve where they expect).",
},
},
},
fn: (rawArgs) => {
const args = (rawArgs ?? {}) as Partial<ChangeWorkspaceArgs>;
if (typeof args.path !== "string" || args.path.trim() === "") {
throw new Error("change_workspace: `path` must be a non-empty string");
}
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded =
args.path.startsWith("~") && home ? pathMod.join(home, args.path.slice(1)) : args.path;
const abs = pathMod.resolve(expanded);
if (!existsSync(abs)) {
throw new Error(`change_workspace: path does not exist — ${abs}`);
}
try {
if (!statSync(abs).isDirectory()) {
throw new Error(`change_workspace: not a directory — ${abs}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`change_workspace: path does not exist — ${abs}`);
}
throw err;
}
// Always defer to the user. The tool itself does not switch —
// approval drives the swap in App.tsx.
throw new WorkspaceConfirmationError(abs);
},
});
return registry;
}
+27
View File
@@ -54,4 +54,31 @@ describe("codeSystemPrompt", () => {
// pinned .gitignore block is the authoritative denylist.
expect(CODE_SYSTEM_PROMPT).toMatch(/dependency.*build.*VCS|skip/i);
});
describe("semantic_search routing fragment", () => {
it("is absent by default (no index registered)", () => {
const out = codeSystemPrompt(root);
expect(out).not.toMatch(/# Search routing/);
});
it("is appended when hasSemanticSearch is true", () => {
const out = codeSystemPrompt(root, { hasSemanticSearch: true });
expect(out).toMatch(/# Search routing/);
expect(out).toMatch(/semantic_search/);
expect(out).toMatch(/search_content/);
expect(out).toMatch(/Descriptive queries/);
});
it("places the routing fragment BEFORE the .gitignore section", () => {
// .gitignore content can change between sessions; the routing
// fragment must sit before it so the cacheable portion of the
// prompt remains contiguous.
writeFileSync(join(root, ".gitignore"), "node_modules\n");
const out = codeSystemPrompt(root, { hasSemanticSearch: true });
const routingAt = out.indexOf("# Search routing");
const gitignoreAt = out.indexOf("# Project .gitignore");
expect(routingAt).toBeGreaterThan(0);
expect(gitignoreAt).toBeGreaterThan(routingAt);
});
});
});
+42
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
addProjectShellAllowed,
clearProjectShellAllowed,
editModeHintShown,
isPlausibleKey,
loadApiKey,
@@ -13,6 +14,7 @@ import {
markEditModeHintShown,
readConfig,
redactKey,
removeProjectShellAllowed,
saveApiKey,
saveEditMode,
saveReasoningEffort,
@@ -173,6 +175,46 @@ describe("config", () => {
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
});
it("removeProjectShellAllowed drops one entry by exact match", () => {
addProjectShellAllowed("/a", "npm install", path);
addProjectShellAllowed("/a", "git commit", path);
expect(removeProjectShellAllowed("/a", "npm install", path)).toBe(true);
expect(loadProjectShellAllowed("/a", path)).toEqual(["git commit"]);
});
it("removeProjectShellAllowed returns false when prefix isn't stored", () => {
addProjectShellAllowed("/a", "npm install", path);
expect(removeProjectShellAllowed("/a", "git commit", path)).toBe(false);
expect(loadProjectShellAllowed("/a", path)).toEqual(["npm install"]);
});
it("removeProjectShellAllowed doesn't prefix-match (literal only)", () => {
addProjectShellAllowed("/a", "git push origin main", path);
expect(removeProjectShellAllowed("/a", "git push", path)).toBe(false);
expect(loadProjectShellAllowed("/a", path)).toEqual(["git push origin main"]);
});
it("removeProjectShellAllowed scoped to project (doesn't leak across roots)", () => {
addProjectShellAllowed("/a", "lint", path);
addProjectShellAllowed("/b", "lint", path);
expect(removeProjectShellAllowed("/a", "lint", path)).toBe(true);
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
expect(loadProjectShellAllowed("/b", path)).toEqual(["lint"]);
});
it("clearProjectShellAllowed wipes one project, returns count, leaves others alone", () => {
addProjectShellAllowed("/a", "lint", path);
addProjectShellAllowed("/a", "test", path);
addProjectShellAllowed("/b", "build", path);
expect(clearProjectShellAllowed("/a", path)).toBe(2);
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
expect(loadProjectShellAllowed("/b", path)).toEqual(["build"]);
});
it("clearProjectShellAllowed returns 0 when nothing stored", () => {
expect(clearProjectShellAllowed("/empty", path)).toBe(0);
});
it("loadEditMode defaults to 'review' when unset", () => {
expect(loadEditMode(path)).toBe("review");
});
+78
View File
@@ -0,0 +1,78 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleSlash } from "../src/cli/ui/slash/dispatch.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../src/index.js";
import { ToolRegistry } from "../src/tools.js";
/**
* Build a minimal loop instance for the slash dispatcher to consume.
* The handler under test never actually calls the loop — it returns
* a SlashResult — so this is just structural plumbing.
*/
function makeLoop(): CacheFirstLoop {
const tools = new ToolRegistry();
return new CacheFirstLoop({
client: new DeepSeekClient({ apiKey: "sk-test" }),
prefix: new ImmutablePrefix({ system: "s", toolSpecs: [] }),
tools,
maxToolIters: 1,
stream: false,
});
}
describe("/init slash handler", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-init-slash-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("refuses outside code mode (no codeRoot)", () => {
const loop = makeLoop();
const result = handleSlash("init", [], loop, {});
expect(result.info).toMatch(/only works in code mode/i);
expect(result.resubmit).toBeUndefined();
});
it("emits the structured init prompt as resubmit when REASONIX.md does not exist", () => {
const loop = makeLoop();
const result = handleSlash("init", [], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
expect(result.resubmit).toMatch(/Initialize REASONIX.md/);
// The hard length cap is the most important constraint — pin it.
expect(result.resubmit).toMatch(/≤\s*80\s*lines/);
// The "STOP after writing" line is load-bearing for flash; pin it
// so a future tightening pass doesn't accidentally drop it.
expect(result.resubmit).toMatch(/STOP/);
expect(result.info).toMatch(/scan the project/);
});
it("refuses overwriting an existing REASONIX.md without `force`", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", [], loop, { codeRoot: tmp });
expect(result.resubmit).toBeUndefined();
expect(result.info).toMatch(/already exists/);
expect(result.info).toMatch(/\/init force/);
});
it("`/init force` proceeds even when REASONIX.md exists", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", ["force"], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
expect(result.resubmit).toMatch(/Initialize REASONIX.md/);
expect(result.info).toMatch(/scan the project/);
});
it("`force` matching is case-insensitive", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", ["FORCE"], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
});
});
+57
View File
@@ -327,6 +327,63 @@ describe("CacheFirstLoop (non-streaming)", () => {
void fetchSpy;
});
it("does not bleed the prior turn's abort into the next step", async () => {
// Regression: a user pressing Esc once would put _turnAbort into
// an aborted state; the iter-0 abort branch handled it but didn't
// reset the controller. Every subsequent step() then carried the
// stale aborted state forward and bailed out with another
// "stopped without producing a summary" before any model call ran.
// The session was effectively dead until restart.
const reg = new ToolRegistry();
reg.register({
name: "probe",
description: "no-op",
parameters: { type: "object", properties: {} },
fn: async () => "ok",
});
const chainingToolCall = {
content: "",
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
};
const finalAnswer = { content: "second turn ran cleanly", tool_calls: [] };
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: fakeFetch([chainingToolCall, finalAnswer]) as unknown as typeof fetch,
});
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
tools: reg,
stream: false,
maxToolIters: 16,
});
// Turn 1 — abort mid-flight.
let aborted = false;
for await (const ev of loop.step("first")) {
if (!aborted && ev.role === "tool") {
aborted = true;
loop.abort();
}
}
// Turn 2 — fresh user input; should reach the second model call
// and yield its output. If the bug is back, we see iter-0 abort
// again and never see "second turn ran cleanly".
const turn2Events: { role: string; content?: string }[] = [];
for await (const ev of loop.step("second")) {
turn2Events.push({ role: ev.role, content: ev.content });
}
const finals = turn2Events.filter((e) => e.role === "assistant_final");
expect(finals).toHaveLength(1);
expect(finals[0]!.content).toBe("second turn ran cleanly");
// No "aborted at iter 0" warning on turn 2.
expect(
turn2Events.some((e) => e.role === "warning" && /aborted at iter/.test(e.content ?? "")),
).toBe(false);
});
it("forces a summary when maxToolIters is exhausted, instead of stopping silently", async () => {
// Give a registered tool so the repair layer doesn't strip the fake
// tool_calls for referring to an unknown name.
+27
View File
@@ -72,6 +72,33 @@ describe("stripMath", () => {
expect(stripMath("\\sqrt{49}")).toBe("√(49)");
});
it("preserves Windows-style backslash paths in plain prose", () => {
// Regression: the catch-all `\\[a-zA-Z]+` LaTeX fallback wiped
// five characters from "F:\TEST1" — `\TEST` matched as an
// unknown LaTeX command and was deleted, leaving "F:1". The
// early-exit guard skips the math pipeline entirely when no
// math indicator is present.
expect(stripMath("F:\\TEST1")).toBe("F:\\TEST1");
expect(stripMath("F:\\TEST1\\report_output")).toBe("F:\\TEST1\\report_output");
expect(stripMath("see C:\\Users\\name\\Documents\\foo.py")).toBe(
"see C:\\Users\\name\\Documents\\foo.py",
);
// Literal backslash-letter in prose (not a path) also survives.
expect(stripMath("the regex \\d+ matches digits")).toBe("the regex \\d+ matches digits");
});
it("still processes math when both a path and an equation appear together", () => {
// Mixed input: the math indicator (`\frac`) IS present, so the
// pipeline runs. The guard is "all-or-nothing" by design — once
// we know there's math we transform the whole string. Path
// chars in this case may collide with the catch-all, which is
// acceptable (no real chat output mixes Windows paths with
// LaTeX math, and even if it did, surfacing the math correctly
// matters more than preserving the path verbatim).
const out = stripMath("save to F:\\out then compute \\frac{a}{b}");
expect(out).toContain("(a)/(b)");
});
it("the full user-reported line no longer leaks raw LaTeX", () => {
const input =
"总路程:2d km 总时间:t_1 + t_2 = \\dfrac{d}{30} + \\dfrac{d}{60} = \\dfrac{2d}{60} + \\dfrac{d}{60} = \\dfrac{3d}{60} = \\dfrac{d}{20} 小时 平均速度:v_{avg} = \\frac{总路程}{总时间} = 40 km/h";
+27
View File
@@ -95,3 +95,30 @@ describe("parseMcpSpec: sse", () => {
expect(spec.transport).toBe("stdio");
});
});
describe("parseMcpSpec: streamable-http", () => {
it("parses an anonymous streamable+https URL and strips the prefix", () => {
const spec = parseMcpSpec("streamable+https://example.com/mcp");
expect(spec.transport).toBe("streamable-http");
if (spec.transport !== "streamable-http") throw new Error("unreachable");
expect(spec.name).toBeNull();
expect(spec.url).toBe("https://example.com/mcp");
});
it("parses a namespaced streamable+http URL (localhost dev)", () => {
const spec = parseMcpSpec("local=streamable+http://127.0.0.1:9000/mcp");
if (spec.transport !== "streamable-http") throw new Error("unreachable");
expect(spec.name).toBe("local");
expect(spec.url).toBe("http://127.0.0.1:9000/mcp");
});
it("is case-insensitive on the streamable+ prefix", () => {
const spec = parseMcpSpec("STREAMABLE+HTTPS://example.com/mcp");
expect(spec.transport).toBe("streamable-http");
});
it("plain https without `streamable+` still routes to SSE for back-compat", () => {
const spec = parseMcpSpec("https://example.com/mcp");
expect(spec.transport).toBe("sse");
});
});
+379
View File
@@ -0,0 +1,379 @@
/**
* Streamable HTTP transport tests.
*
* Mirrors `mcp-sse.test.ts`'s in-process http.Server pattern. The fake
* server speaks the 2025-03-26 Streamable HTTP wire shape directly —
* it accepts POSTs and responds with one of:
* - 202 Accepted (notification ack)
* - 200 + application/json (single response)
* - 200 + text/event-stream (one or more SSE `event: message` frames)
* It also mints a `Mcp-Session-Id` on the first response so the client
* has to round-trip the header on subsequent requests.
*/
import { type IncomingMessage, type ServerResponse, createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { McpClient } from "../src/mcp/client.js";
import { StreamableHttpTransport } from "../src/mcp/streamable-http.js";
import { MCP_PROTOCOL_VERSION } from "../src/mcp/types.js";
interface FakeServer {
url: string;
requests: Array<{
method: string;
url: string;
body?: string;
headers: Record<string, string | string[] | undefined>;
}>;
stop: () => Promise<void>;
}
interface FakeOptions {
/** Override path (default `/mcp`). */
path?: string;
/** Hand back this session id on the initialize response. Default "sess-1". */
sessionId?: string;
/**
* Return a JSON-RPC reply for an incoming request.
* - If the value is `{ stream: [...] }`, the server writes those frames
* on an SSE response.
* - If `undefined`, the server replies 202 Accepted (notification ack).
* - Otherwise, the value is sent as a single application/json body.
*/
reply?: (body: unknown) => unknown | { stream: unknown[] } | undefined;
/**
* Sites that need to inject failures for a specific request shape.
* Lookup happens after `reply` returns so the failure can short-circuit
* the normal path.
*/
forceStatus?: (body: unknown) => { status: number; body?: string } | undefined;
}
function startFakeServer(opts: FakeOptions = {}): Promise<FakeServer> {
const path = opts.path ?? "/mcp";
const sessionId = opts.sessionId ?? "sess-1";
const requests: FakeServer["requests"] = [];
let mintedSession = false;
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (!req.url || !req.url.startsWith(path)) {
res.writeHead(404);
res.end("not found");
return;
}
if (req.method === "POST") {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
requests.push({
method: "POST",
url: req.url ?? "",
body,
headers: req.headers,
});
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400);
res.end("bad json");
return;
}
const forced = opts.forceStatus?.(parsed);
if (forced) {
res.writeHead(forced.status);
res.end(forced.body ?? "");
return;
}
const reply = opts.reply?.(parsed);
// The session id is minted on the first response that has a
// body — i.e. the initialize response. Notifications (202) and
// unknown methods don't get a session header until then.
const responseHeaders: Record<string, string> = {};
const isInitialize =
typeof parsed === "object" &&
parsed !== null &&
(parsed as { method?: string }).method === "initialize";
if (isInitialize && !mintedSession) {
responseHeaders["mcp-session-id"] = sessionId;
mintedSession = true;
}
if (reply === undefined) {
// notification → 202 Accepted, no body
res.writeHead(202, responseHeaders);
res.end();
return;
}
if (
typeof reply === "object" &&
reply !== null &&
Array.isArray((reply as { stream?: unknown[] }).stream)
) {
const frames = (reply as { stream: unknown[] }).stream;
res.writeHead(200, {
...responseHeaders,
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
for (const frame of frames) {
res.write(`event: message\ndata: ${JSON.stringify(frame)}\n\n`);
}
res.end();
return;
}
res.writeHead(200, {
...responseHeaders,
"content-type": "application/json",
});
res.end(JSON.stringify(reply));
});
return;
}
res.writeHead(405);
res.end("method not allowed");
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${port}${path}`,
requests,
stop: () =>
new Promise<void>((r) => {
server.close(() => r());
server.closeAllConnections?.();
}),
});
});
});
}
describe("StreamableHttpTransport: POST round-trip", () => {
let fake: FakeServer | null = null;
beforeEach(() => {
fake = null;
});
afterEach(async () => {
await fake?.stop();
});
it("delivers a single application/json response back through messages()", async () => {
fake = await startFakeServer({
reply: (req) => ({
jsonrpc: "2.0",
id: (req as { id: number }).id,
result: { ok: true },
}),
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", id: 1, method: "ping" });
const iter = transport.messages();
const first = await iter.next();
expect(first.done).toBe(false);
expect(first.value).toMatchObject({ id: 1, result: { ok: true } });
await transport.close();
});
it("treats 202 Accepted as a no-op (notification ack, no message yielded)", async () => {
fake = await startFakeServer({
reply: (req) => {
// Notifications have no id — return undefined → 202.
if ((req as { id?: number }).id === undefined) return undefined;
return { jsonrpc: "2.0", id: (req as { id: number }).id, result: {} };
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
// Now send a real request so we can prove the iterator only got
// the response (one message), not the notification (no message).
await transport.send({ jsonrpc: "2.0", id: 42, method: "ping" });
const iter = transport.messages();
const first = await iter.next();
expect(first.value).toMatchObject({ id: 42 });
await transport.close();
});
it("captures Mcp-Session-Id from the initialize response and echoes it on subsequent POSTs", async () => {
fake = await startFakeServer({
sessionId: "alpha-session",
reply: (req) => {
const r = req as { id?: number; method: string };
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake", version: "0.0.0" },
capabilities: {},
},
};
}
if (r.id === undefined) return undefined;
return { jsonrpc: "2.0", id: r.id, result: { echoed: r.method } };
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
await client.initialize();
expect(transport.getSessionId()).toBe("alpha-session");
await client.listTools().catch(() => undefined);
await client.close();
// First POST = initialize: no session header yet (we don't have one).
// Second POST = notifications/initialized: should have session id.
// Third POST = tools/list: should have session id.
const posts = fake.requests.filter((r) => r.method === "POST");
expect(posts.length).toBeGreaterThanOrEqual(2);
expect(posts[0]!.headers["mcp-session-id"]).toBeUndefined();
for (let i = 1; i < posts.length; i++) {
expect(posts[i]!.headers["mcp-session-id"]).toBe("alpha-session");
}
});
it("delivers SSE-streamed responses with multiple frames in order", async () => {
fake = await startFakeServer({
reply: (req) => {
const r = req as { id: number };
return {
stream: [
// a progress notification first
{ jsonrpc: "2.0", method: "notifications/progress", params: { progress: 50 } },
// then the real response
{ jsonrpc: "2.0", id: r.id, result: { done: true } },
],
};
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", id: 7, method: "slow_tool" });
const iter = transport.messages();
const first = await iter.next();
expect(first.value).toMatchObject({ method: "notifications/progress" });
const second = await iter.next();
expect(second.value).toMatchObject({ id: 7, result: { done: true } });
await transport.close();
});
it("drives a full McpClient initialize → tools/list round-trip", async () => {
fake = await startFakeServer({
reply: (raw) => {
const r = raw as { id?: number; method: string };
if (r.id === undefined) return undefined;
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake-streamable", version: "0.0.0" },
capabilities: { tools: { listChanged: false } },
},
};
}
if (r.method === "tools/list") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
tools: [
{
name: "ping",
description: "responds pong",
inputSchema: { type: "object", properties: {} },
},
],
},
};
}
return {
jsonrpc: "2.0",
id: r.id,
error: { code: -32601, message: `no such method ${r.method}` },
};
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
const info = await client.initialize();
expect(info.serverInfo.name).toBe("fake-streamable");
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0]!.name).toBe("ping");
await client.close();
// initialize + notifications/initialized + tools/list = 3 POSTs.
const posts = fake.requests.filter((r) => r.method === "POST");
expect(posts).toHaveLength(3);
const methods = posts.map((p) => (JSON.parse(p.body!) as { method: string }).method);
expect(methods).toEqual(["initialize", "notifications/initialized", "tools/list"]);
});
it("surfaces a 404 with an existing session id as 'session expired'", async () => {
fake = await startFakeServer({
reply: (req) => {
const r = req as { id?: number; method: string };
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake", version: "0.0.0" },
capabilities: {},
},
};
}
if (r.id === undefined) return undefined;
return { jsonrpc: "2.0", id: r.id, result: {} };
},
forceStatus: (req) => {
const r = req as { method?: string };
if (r.method === "tools/list") {
return { status: 404, body: "session expired" };
}
return undefined;
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
await client.initialize();
expect(transport.getSessionId()).not.toBeNull();
// Once the session id is set, a 404 should surface as a clear error.
await expect(client.listTools()).rejects.toThrow(/session expired/i);
await client.close();
});
it("surfaces a non-OK response as an error from send()", async () => {
fake = await startFakeServer({
forceStatus: () => ({ status: 500, body: "boom" }),
});
const transport = new StreamableHttpTransport({ url: fake.url });
await expect(transport.send({ jsonrpc: "2.0", id: 1, method: "ping" })).rejects.toThrow(/500/);
await transport.close();
});
it("close() unblocks an idle messages() iterator", async () => {
fake = await startFakeServer({ reply: () => undefined });
const transport = new StreamableHttpTransport({ url: fake.url });
const iter = transport.messages();
const next = iter.next();
await transport.close();
const settled = await next;
expect(settled.done).toBe(true);
});
});
+191
View File
@@ -0,0 +1,191 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleSlash } from "../src/cli/ui/slash/dispatch.js";
import { addProjectShellAllowed, loadProjectShellAllowed } from "../src/config.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../src/index.js";
import { ToolRegistry } from "../src/tools.js";
function makeLoop(): CacheFirstLoop {
return new CacheFirstLoop({
client: new DeepSeekClient({ apiKey: "sk-test" }),
prefix: new ImmutablePrefix({ system: "s", toolSpecs: [] }),
tools: new ToolRegistry(),
maxToolIters: 1,
stream: false,
});
}
describe("/permissions slash handler", () => {
let dir: string;
let cfgPath: string;
let projectRoot: string;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "reasonix-perms-slash-"));
cfgPath = join(dir, "config.json");
projectRoot = join(dir, "project");
// Redirect ~/.reasonix → temp dir so the handler's calls (which use
// defaultConfigPath) land in `cfgPath`. config.test.ts skips this by
// passing `path` explicitly to every helper, but the slash handler
// hardcodes the default — so we have to redirect HOME instead.
process.env.HOME = dir;
process.env.USERPROFILE = dir;
});
afterEach(() => {
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
if (originalHome === undefined) {
// biome-ignore lint/performance/noDelete: the string "undefined" leaks into process.env otherwise
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalUserProfile === undefined) {
// biome-ignore lint/performance/noDelete: same reason
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}
});
it("bare /permissions lists builtin entries (always shown)", () => {
const result = handleSlash("permissions", [], makeLoop(), {});
expect(result.info).toMatch(/Builtin allowlist/);
expect(result.info).toMatch(/git/);
expect(result.info).toMatch(/Project allowlist/);
});
it("bare /permissions surfaces yolo-mode banner when active", () => {
const result = handleSlash("permissions", [], makeLoop(), {
codeRoot: projectRoot,
editMode: "yolo",
});
expect(result.info).toMatch(/YOLO/);
expect(result.info).toMatch(/bypassed/);
});
it("bare /permissions lists project entries with 1-based indices", () => {
addProjectShellAllowed(projectRoot, "npm run build", join(dir, ".reasonix", "config.json"));
addProjectShellAllowed(projectRoot, "deploy.sh", join(dir, ".reasonix", "config.json"));
const result = handleSlash("permissions", [], makeLoop(), {
codeRoot: projectRoot,
editMode: "review",
});
expect(result.info).toMatch(/1\.\s+npm run build/);
expect(result.info).toMatch(/2\.\s+deploy\.sh/);
});
it("/permissions add persists a new prefix", () => {
const result = handleSlash("permissions", ["add", "npm", "run", "build"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/added.*npm run build/);
expect(loadProjectShellAllowed(projectRoot, join(dir, ".reasonix", "config.json"))).toContain(
"npm run build",
);
});
it("/permissions add rejects an empty prefix with a usage hint", () => {
const result = handleSlash("permissions", ["add"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/usage:\s+\/permissions add/);
});
it("/permissions add notes when the target is already in the builtin list", () => {
const result = handleSlash("permissions", ["add", "git", "status"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/builtin allowlist/i);
// Should NOT have written a redundant project entry.
expect(loadProjectShellAllowed(projectRoot, join(dir, ".reasonix", "config.json"))).toEqual([]);
});
it("/permissions remove drops by exact prefix", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "npm run build", cfgFile);
addProjectShellAllowed(projectRoot, "deploy.sh", cfgFile);
const result = handleSlash("permissions", ["remove", "deploy.sh"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/removed.*deploy\.sh/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["npm run build"]);
});
it("/permissions remove drops by 1-based project index", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
addProjectShellAllowed(projectRoot, "gamma", cfgFile);
const result = handleSlash("permissions", ["remove", "2"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/removed.*beta/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["alpha", "gamma"]);
});
it("/permissions remove flags an out-of-range index", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
const result = handleSlash("permissions", ["remove", "5"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/index out of range/);
});
it("/permissions remove of an unknown prefix replies 'no such project entry'", () => {
const result = handleSlash("permissions", ["remove", "ghost"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/no such project entry/);
});
it("/permissions remove on a builtin entry refuses (read-only)", () => {
const result = handleSlash("permissions", ["remove", "git", "status"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/builtin/i);
expect(result.info).toMatch(/can't be removed/i);
});
it("/permissions clear without 'confirm' asks for confirmation", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
const result = handleSlash("permissions", ["clear"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/about to drop 2 project allowlist/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["alpha", "beta"]);
});
it("/permissions clear confirm wipes the project list", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
const result = handleSlash("permissions", ["clear", "confirm"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/cleared 2 project allowlist/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual([]);
});
it("mutating subcommands refuse without a codeRoot", () => {
const r1 = handleSlash("permissions", ["add", "lint"], makeLoop(), {});
expect(r1.info).toMatch(/only available inside `reasonix code`/);
const r2 = handleSlash("permissions", ["remove", "lint"], makeLoop(), {});
expect(r2.info).toMatch(/only available inside `reasonix code`/);
const r3 = handleSlash("permissions", ["clear", "confirm"], makeLoop(), {});
expect(r3.info).toMatch(/only available inside `reasonix code`/);
});
it("'perms' is registered as an alias for 'permissions'", () => {
const result = handleSlash("perms", [], makeLoop(), {});
expect(result.info).toMatch(/Builtin allowlist/);
});
it("unknown subcommand surfaces the usage block", () => {
const result = handleSlash("permissions", ["wat"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/usage: \/permissions/);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { promises as fs } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { bootstrapSemanticSearchInCodeMode } from "../src/index/semantic/tool.js";
import { ToolRegistry } from "../src/tools.js";
describe("bootstrapSemanticSearchInCodeMode", () => {
let root: string;
let tools: ToolRegistry;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "reasonix-bootstrap-"));
tools = new ToolRegistry();
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("registers the tool when an index already exists", async () => {
const semanticDir = join(root, ".reasonix", "semantic");
await fs.mkdir(semanticDir, { recursive: true });
await fs.writeFile(
join(semanticDir, "index.meta.json"),
JSON.stringify({
version: 1,
model: "nomic-embed-text",
dim: 768,
updatedAt: new Date().toISOString(),
}),
"utf8",
);
await fs.writeFile(join(semanticDir, "index.jsonl"), "", "utf8");
const result = await bootstrapSemanticSearchInCodeMode(tools, root);
expect(result.enabled).toBe(true);
expect(tools.get("semantic_search")).toBeDefined();
});
it("silently skips (no prompt) when no index is built — even with Ollama present", async () => {
// The contract: bootstrap NEVER prompts at startup, regardless of
// local Ollama state. Setup happens via the explicit
// `reasonix index` command + `/semantic` slash. This is the
// load-bearing UX guarantee — `npx reasonix code` must be silent
// for users who haven't opted in.
const result = await bootstrapSemanticSearchInCodeMode(tools, root);
expect(result.enabled).toBe(false);
expect(tools.get("semantic_search")).toBeUndefined();
});
});
+147
View File
@@ -0,0 +1,147 @@
import { promises as fs } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chunkDirectory, chunkText, walkChunks } from "../src/index/semantic/chunker.js";
describe("chunker", () => {
describe("chunkText (pure)", () => {
it("splits a file into overlapping line windows", () => {
const text = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join("\n");
const chunks = chunkText(text, "src/foo.ts", 30, 5);
expect(chunks.length).toBeGreaterThan(1);
// First chunk covers 1..30
expect(chunks[0]?.startLine).toBe(1);
expect(chunks[0]?.endLine).toBe(30);
// Stride = 30 - 5 = 25
expect(chunks[1]?.startLine).toBe(26);
expect(chunks[1]?.endLine).toBe(55);
// Last chunk's endLine never exceeds total
const last = chunks[chunks.length - 1];
expect(last?.endLine).toBeLessThanOrEqual(100);
});
it("produces a single chunk for a small file", () => {
const chunks = chunkText("a\nb\nc\n", "x.ts", 60, 12);
expect(chunks).toHaveLength(1);
expect(chunks[0]?.startLine).toBe(1);
});
it("returns no chunks for empty input", () => {
expect(chunkText("", "empty.ts", 60, 12)).toEqual([]);
});
it("preserves text content in the chunk body", () => {
const text = "alpha\nbeta\ngamma\n";
const [chunk] = chunkText(text, "f.ts", 60, 0);
expect(chunk?.text).toContain("alpha");
expect(chunk?.text).toContain("gamma");
});
it("guards against overlap >= window (would loop forever)", () => {
const text = Array.from({ length: 30 }, (_, i) => `l${i}`).join("\n");
// overlap clamped to windowLines - 1 inside walkChunks; chunkText
// itself trusts the caller, so we exercise sane stride here.
const chunks = chunkText(text, "f.ts", 10, 3);
expect(chunks.length).toBeGreaterThan(1);
// Check that startLines monotonically increase.
for (let i = 1; i < chunks.length; i++) {
expect(chunks[i]!.startLine).toBeGreaterThan(chunks[i - 1]!.startLine);
}
});
it("splits a multi-line chunk that exceeds maxChunkChars at line boundaries", () => {
// 30 lines of 200 chars each = 6000 chars, with maxChunkChars=2500
// we should get multiple sub-chunks, none over the cap.
const longLine = "x".repeat(200);
const text = Array.from({ length: 30 }, () => longLine).join("\n");
const chunks = chunkText(text, "long.ts", 30, 0, 2500);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(c.text.length).toBeLessThanOrEqual(2500);
}
// Line ranges cover the whole file, no gaps
const minStart = Math.min(...chunks.map((c) => c.startLine));
const maxEnd = Math.max(...chunks.map((c) => c.endLine));
expect(minStart).toBe(1);
expect(maxEnd).toBe(30);
});
it("hard-truncates a single line that exceeds maxChunkChars", () => {
// One line of 5000 chars, cap at 1000. Should produce one chunk
// containing the truncated line.
const huge = "y".repeat(5000);
const chunks = chunkText(huge, "minified.js", 60, 12, 1000);
expect(chunks).toHaveLength(1);
expect(chunks[0]?.text.length).toBeLessThanOrEqual(1000);
expect(chunks[0]?.startLine).toBe(1);
expect(chunks[0]?.endLine).toBe(1);
});
it("passes through chunks smaller than the cap unchanged", () => {
const chunks = chunkText("hi\nthere\n", "x.ts", 60, 12, 4000);
expect(chunks).toHaveLength(1);
expect(chunks[0]?.text).toBe("hi\nthere");
});
});
describe("walkChunks (filesystem)", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "reasonix-chunk-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("skips node_modules and lockfiles", async () => {
await fs.mkdir(join(root, "node_modules", "foo"), { recursive: true });
await fs.writeFile(join(root, "node_modules", "foo", "x.ts"), "noisy\n");
await fs.writeFile(join(root, "package-lock.json"), '{"x": 1}\n');
await fs.writeFile(join(root, "main.ts"), "export const a = 1;\n");
const chunks = await chunkDirectory(root);
const paths = chunks.map((c) => c.path);
expect(paths).toContain("main.ts");
expect(paths.some((p) => p.startsWith("node_modules/"))).toBe(false);
expect(paths).not.toContain("package-lock.json");
});
it("skips binary extensions", async () => {
await fs.writeFile(join(root, "a.ts"), "ok\n");
await fs.writeFile(join(root, "b.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const chunks = await chunkDirectory(root);
expect(chunks.some((c) => c.path === "a.ts")).toBe(true);
expect(chunks.some((c) => c.path === "b.png")).toBe(false);
});
it("normalizes paths to forward slashes", async () => {
await fs.mkdir(join(root, "src", "deep"), { recursive: true });
await fs.writeFile(join(root, "src", "deep", "f.ts"), "x\n");
const chunks = await chunkDirectory(root);
const target = chunks.find((c) => c.path.endsWith("f.ts"));
expect(target?.path).toBe("src/deep/f.ts");
});
it("skips files with embedded NUL bytes", async () => {
// .ts extension passes the binary-ext filter, NUL sniff should
// catch the binary content.
await fs.writeFile(join(root, "fake.ts"), "header\0body\n");
await fs.writeFile(join(root, "real.ts"), "real text\n");
const chunks = await chunkDirectory(root);
const paths = chunks.map((c) => c.path);
expect(paths).toContain("real.ts");
expect(paths).not.toContain("fake.ts");
});
it("works as an async iterable for streaming", async () => {
await fs.writeFile(join(root, "a.ts"), "x\n");
await fs.writeFile(join(root, "b.ts"), "y\n");
const seen: string[] = [];
for await (const c of walkChunks(root)) seen.push(c.path);
expect(seen.sort()).toEqual(["a.ts", "b.ts"]);
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { embedAll } from "../src/index/semantic/embedding.js";
describe("embedAll fault tolerance", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
/**
* Helper: stub global fetch so we can simulate Ollama responses
* without spinning up a real daemon. Returns whatever the per-call
* predicate produces.
*/
function stubFetch(handler: (callIdx: number) => Promise<Response> | Response) {
let callIdx = 0;
globalThis.fetch = vi.fn(async () => {
const r = await handler(callIdx++);
return r;
}) as unknown as typeof globalThis.fetch;
}
function jsonOk(embedding: number[]): Response {
return new Response(JSON.stringify({ embedding }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function jsonErr(status: number, body: unknown): Response {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
it("returns null in the slot for a single failing chunk and continues", async () => {
stubFetch((i) => {
if (i === 1) {
return jsonErr(500, { error: "the input length exceeds the context length" });
}
return jsonOk([1, 0, 0]);
});
const skipped: Array<{ index: number; err: unknown }> = [];
const out = await embedAll(["a", "b", "c"], {
onError: (index, err) => skipped.push({ index, err }),
});
expect(out).toHaveLength(3);
expect(out[0]).toBeInstanceOf(Float32Array);
expect(out[1]).toBeNull();
expect(out[2]).toBeInstanceOf(Float32Array);
expect(skipped).toHaveLength(1);
expect(skipped[0]?.index).toBe(1);
});
it("aborts on signal but doesn't surface aborts as per-chunk errors", async () => {
stubFetch(() => jsonOk([1, 0, 0]));
const ac = new AbortController();
ac.abort(new Error("user cancelled"));
await expect(embedAll(["a", "b"], { signal: ac.signal })).rejects.toThrow(/aborted/);
});
it("returns all-null when every chunk fails", async () => {
stubFetch(() => jsonErr(500, { error: "context length" }));
const errors: number[] = [];
const out = await embedAll(["a", "b"], {
onError: (i) => errors.push(i),
});
expect(out).toEqual([null, null]);
expect(errors).toEqual([0, 1]);
});
it("calls onProgress once per chunk regardless of outcome", async () => {
stubFetch((i) => (i === 0 ? jsonErr(500, "x") : jsonOk([1, 0])));
const ticks: Array<{ done: number; total: number }> = [];
await embedAll(["x", "y"], {
onProgress: (done, total) => ticks.push({ done, total }),
});
expect(ticks).toEqual([
{ done: 1, total: 2 },
{ done: 2, total: 2 },
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { detectLocale, resetLocaleCache, t } from "../src/index/semantic/i18n.js";
describe("semantic i18n", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
resetLocaleCache();
});
afterEach(() => {
// Restore env so test order can't leak through cached locale.
for (const k of Object.keys(process.env)) {
if (!(k in originalEnv)) delete process.env[k];
}
Object.assign(process.env, originalEnv);
resetLocaleCache();
});
describe("detectLocale", () => {
it("returns 'zh' when REASONIX_LANG=zh", () => {
process.env.REASONIX_LANG = "zh";
expect(detectLocale()).toBe("zh");
});
it("returns 'en' when REASONIX_LANG=en (overrides system locale)", () => {
process.env.REASONIX_LANG = "en";
process.env.LANG = "zh_CN.UTF-8";
expect(detectLocale()).toBe("en");
});
it("returns 'zh' for LANG=zh_CN.UTF-8", () => {
process.env.REASONIX_LANG = undefined;
process.env.LANG = "zh_CN.UTF-8";
expect(detectLocale()).toBe("zh");
});
it("returns 'zh' for LANG=zh_TW.Big5", () => {
process.env.REASONIX_LANG = undefined;
process.env.LANG = "zh_TW.Big5";
expect(detectLocale()).toBe("zh");
});
it("returns 'en' when LANG points elsewhere and no override", () => {
process.env.REASONIX_LANG = undefined;
process.env.LANG = "en_US.UTF-8";
process.env.LC_ALL = undefined;
process.env.LC_MESSAGES = undefined;
// Note: Intl fallback may still detect zh on a Chinese system,
// but we can at least assert non-zh LANG doesn't produce zh
// when REASONIX_LANG is absent. We don't pin Intl here because
// the test machine's system locale isn't fixed.
const got = detectLocale();
expect(["zh", "en"]).toContain(got); // sanity: only one of two
});
});
describe("t()", () => {
it("substitutes {placeholders} from vars", () => {
process.env.REASONIX_LANG = "en";
const out = t("modelPullFailed", { model: "nomic-embed-text", code: 137 });
expect(out).toContain("nomic-embed-text");
expect(out).toContain("137");
});
it("returns Chinese strings under zh locale", () => {
process.env.REASONIX_LANG = "zh";
const out = t("ollamaNotFound");
expect(out).toMatch(/未找到/);
});
it("falls back to English when a key is only present in EN dict", () => {
// Every ZH entry that exists must also exist in EN; the table
// is structured so a missing ZH translation falls through. We
// can't easily induce a missing-zh state without mutating the
// module, so we exercise the happy path: a key that exists in
// both renders the zh form.
process.env.REASONIX_LANG = "zh";
const out = t("daemonReady", { pid: " (pid 123)" });
expect(out).toMatch(/守护进程/);
expect(out).toContain("(pid 123)");
});
it("leaves {var} literal when the key is missing from vars", () => {
process.env.REASONIX_LANG = "en";
const out = t("modelPullFailed", { model: "x" }); // no `code`
expect(out).toContain("x");
expect(out).toContain("{code}");
});
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { findOllamaBinary } from "../src/index/semantic/ollama-launcher.js";
describe("ollama-launcher", () => {
describe("findOllamaBinary", () => {
it("returns null when `ollama` is not on PATH (or a string when it is)", () => {
// We can't pre-condition on the test runner having (or not having)
// ollama installed, so we only assert the return type contract and
// that the function is non-throwing.
const result = findOllamaBinary();
expect(result === null || typeof result === "string").toBe(true);
if (typeof result === "string") {
expect(result.length).toBeGreaterThan(0);
expect(result).not.toMatch(/\n/);
}
});
it("does not throw on systems with restrictive PATH lookup tools", () => {
// Defensive — `which` / `where` returning non-zero must not bubble.
// Calling twice in a row exercises any state we might accidentally
// accumulate.
expect(() => findOllamaBinary()).not.toThrow();
expect(() => findOllamaBinary()).not.toThrow();
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import { promises as fs } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { renderSemanticStatus } from "../src/cli/ui/slash/handlers/semantic.js";
import { resetLocaleCache } from "../src/index/semantic/i18n.js";
describe("/semantic status renderer", () => {
let root: string;
beforeEach(async () => {
process.env.REASONIX_LANG = "en";
resetLocaleCache();
root = await mkdtemp(join(tmpdir(), "reasonix-slash-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
process.env.REASONIX_LANG = undefined;
resetLocaleCache();
});
it("reports 'enabled' with chunk + file count when an index exists", async () => {
const dir = join(root, ".reasonix", "semantic");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
join(dir, "index.meta.json"),
JSON.stringify({
version: 1,
model: "nomic-embed-text",
dim: 768,
updatedAt: new Date().toISOString(),
}),
"utf8",
);
await fs.writeFile(
join(dir, "index.jsonl"),
[
JSON.stringify({ p: "src/a.ts", s: 1, e: 30, m: 0, t: "x", v: "" }),
JSON.stringify({ p: "src/b.ts", s: 1, e: 30, m: 0, t: "y", v: "" }),
JSON.stringify({ p: "src/a.ts", s: 31, e: 60, m: 0, t: "z", v: "" }),
].join("\n"),
"utf8",
);
const out = await renderSemanticStatus(root);
expect(out).toMatch(/enabled/);
expect(out).toContain("3 chunks"); // 3 lines
expect(out).toContain("2 files"); // a.ts + b.ts
});
it("reports 'no index built yet' with how-to-build hint when nothing is set up", async () => {
const out = await renderSemanticStatus(root);
expect(out).toMatch(/no index built/);
expect(out).toContain("reasonix index");
});
it("renders Chinese under zh locale", async () => {
process.env.REASONIX_LANG = "zh";
resetLocaleCache();
const out = await renderSemanticStatus(root);
expect(out).toMatch(/状态/); // header in Chinese
expect(out).toMatch(/还没有索引/);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type IndexEntry,
STORE_VERSION,
SemanticStore,
normalize,
openStore,
} from "../src/index/semantic/store.js";
function unitVector(values: number[]): Float32Array {
return normalize(new Float32Array(values));
}
function entry(
path: string,
startLine: number,
endLine: number,
vec: number[],
mtimeMs = 1700000000000,
): IndexEntry {
return {
path,
startLine,
endLine,
text: `chunk for ${path}:${startLine}-${endLine}`,
embedding: unitVector(vec),
mtimeMs,
};
}
describe("SemanticStore", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "reasonix-store-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("add + search", () => {
it("returns top-K hits ordered by cosine similarity", async () => {
const store = await openStore(dir, "test-model");
await store.add([
entry("a.ts", 1, 30, [1, 0, 0]),
entry("b.ts", 1, 30, [0, 1, 0]),
entry("c.ts", 1, 30, [0, 0, 1]),
entry("d.ts", 1, 30, [0.7, 0.7, 0]),
]);
const q = unitVector([1, 0, 0]);
const hits = store.search(q, 2);
expect(hits).toHaveLength(2);
expect(hits[0]?.entry.path).toBe("a.ts");
// d.ts (0.7,0.7,0) has cosine ~0.707 with (1,0,0) → second.
expect(hits[1]?.entry.path).toBe("d.ts");
expect(hits[0]!.score).toBeGreaterThan(hits[1]!.score);
});
it("respects minScore threshold", async () => {
const store = await openStore(dir, "test-model");
await store.add([entry("a.ts", 1, 30, [1, 0, 0]), entry("b.ts", 1, 30, [0, 1, 0])]);
const q = unitVector([1, 0, 0]);
// (1,0,0) vs (0,1,0) cosine = 0; threshold 0.5 should drop it.
const hits = store.search(q, 5, 0.5);
expect(hits).toHaveLength(1);
expect(hits[0]?.entry.path).toBe("a.ts");
});
it("rejects mismatched dimensionality", async () => {
const store = await openStore(dir, "test-model");
await store.add([entry("a.ts", 1, 30, [1, 0, 0])]);
await expect(store.add([entry("b.ts", 1, 30, [1, 0])])).rejects.toThrow(/dim mismatch/);
});
});
describe("persistence", () => {
it("round-trips entries through the JSONL store", async () => {
const a = await openStore(dir, "test-model");
await a.add([entry("a.ts", 1, 30, [1, 2, 3]), entry("b.ts", 1, 30, [4, 5, 6])]);
const b = await openStore(dir, "test-model");
expect(b.size).toBe(2);
const q = unitVector([1, 2, 3]);
const hits = b.search(q, 1);
expect(hits[0]?.entry.path).toBe("a.ts");
});
it("rejects opening an index built with a different model", async () => {
const a = await openStore(dir, "model-a");
await a.add([entry("a.ts", 1, 30, [1, 0, 0])]);
await expect(openStore(dir, "model-b")).rejects.toThrow(/built with model "model-a"/);
});
it("survives a mid-write crash via tmp-file rename on remove", async () => {
const store = await openStore(dir, "test-model");
await store.add([entry("a.ts", 1, 30, [1, 0, 0]), entry("b.ts", 1, 30, [0, 1, 0])]);
const removed = await store.remove(["a.ts"]);
expect(removed).toBe(1);
const reloaded = await openStore(dir, "test-model");
expect(reloaded.size).toBe(1);
expect(reloaded.all[0]?.path).toBe("b.ts");
});
it("wipe clears disk + memory", async () => {
const store = await openStore(dir, "test-model");
await store.add([entry("a.ts", 1, 30, [1, 0, 0])]);
await store.wipe();
expect(store.empty).toBe(true);
const reloaded = await openStore(dir, "test-model");
expect(reloaded.empty).toBe(true);
});
});
describe("fileMtimes", () => {
it("returns the mtime per indexed file (incremental rebuild driver)", async () => {
const store = await openStore(dir, "test-model");
await store.add([
entry("a.ts", 1, 30, [1, 0, 0], 1000),
entry("a.ts", 31, 60, [0, 1, 0], 1000),
entry("b.ts", 1, 30, [0, 0, 1], 2000),
]);
const mtimes = store.fileMtimes();
expect(mtimes.get("a.ts")).toBe(1000);
expect(mtimes.get("b.ts")).toBe(2000);
expect(mtimes.size).toBe(2);
});
});
describe("normalize helper", () => {
it("produces unit vectors", () => {
const v = normalize(new Float32Array([3, 0, 4]));
const len = Math.sqrt(v[0]! * v[0]! + v[1]! * v[1]! + v[2]! * v[2]!);
expect(len).toBeCloseTo(1, 5);
});
it("is a no-op on the zero vector", () => {
const v = normalize(new Float32Array([0, 0, 0]));
expect(v[0]).toBe(0);
expect(v[1]).toBe(0);
expect(v[2]).toBe(0);
});
});
describe("STORE_VERSION", () => {
it("is exported as a positive integer", () => {
expect(STORE_VERSION).toBeGreaterThanOrEqual(1);
expect(Number.isInteger(STORE_VERSION)).toBe(true);
});
});
describe("constructor identity", () => {
it("exposes indexDir + model on the instance", () => {
const s = new SemanticStore("/tmp/x", "m");
expect(s.indexDir).toBe("/tmp/x");
expect(s.model).toBe("m");
expect(s.empty).toBe(true);
});
});
});
+43
View File
@@ -14,6 +14,7 @@ import {
registerShellTools,
resolveExecutable,
runCommand,
smartDecodeOutput,
tokenizeCommand,
} from "../src/tools/shell.js";
@@ -669,3 +670,45 @@ describe("NeedsConfirmationError", () => {
expect(e.message).not.toMatch(/apply-shell/);
});
});
describe("smartDecodeOutput", () => {
it("decodes valid UTF-8 cleanly", () => {
const buf = Buffer.from("hello 世界", "utf8");
expect(smartDecodeOutput(buf)).toBe("hello 世界");
});
it("returns empty string for empty input", () => {
expect(smartDecodeOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK on Windows for non-UTF-8 bytes", () => {
// "'sed' 不是内部或外部命令" — encoded in GBK (Chinese Windows
// cmd.exe error message). UTF-8 strict decode rejects it; on
// win32 we re-decode as GBK and recover the Chinese text. On
// other platforms we expect the lossy UTF-8 fallback string,
// which is fine — the bug only manifests on Chinese Windows
// anyway.
const gbk = Buffer.from([
0x27, 0x73, 0x65, 0x64, 0x27, 0x20, 0xb2, 0xbb, 0xca, 0xc7, 0xc4, 0xda, 0xb2, 0xbf, 0xc3,
0xfc, 0xc1, 0xee,
]);
const decoded = smartDecodeOutput(gbk);
if (process.platform === "win32") {
expect(decoded).toBe("'sed' 不是内部命令");
} else {
// Non-Windows: takes the lossy UTF-8 path; assert at least
// the ASCII portion survives unmangled.
expect(decoded.startsWith("'sed' ")).toBe(true);
}
});
it("does not split a multi-byte UTF-8 sequence across decode calls", () => {
// The full 6-byte sequence for "你好" decodes cleanly when
// handed to smartDecodeOutput as a single Buffer — this is the
// post-collection contract. (The chunk-aware accumulator in
// runCommand defers decoding until close, so this case can't
// arise there; the test pins the single-buffer contract.)
const buf = Buffer.from("你好", "utf8");
expect(smartDecodeOutput(buf)).toBe("你好");
});
});
+40
View File
@@ -329,6 +329,46 @@ describe("registerSubagentTool", () => {
const parsed = JSON.parse(out);
expect(parsed.success).toBe(false);
});
it("honors a parentSignal that was already aborted at dispatch time", async () => {
// Race we previously dropped on the floor: parent.abort() fires
// before spawn_subagent's listener attach runs. addEventListener
// doesn't replay abort events for already-aborted signals, so the
// listener stayed silent forever and the child ran free until it
// hit its iter budget. Fix: synchronously check `.aborted` at
// attach and forward immediately to childLoop.abort(), and have
// step() carry the aborted state across its _turnAbort reset.
const parent = new ToolRegistry();
let fetchCalls = 0;
const client = new DeepSeekClient({
apiKey: "sk-test",
// If the abort propagation works, fetch is never called — the
// child loop bails at iter 0 because its signal is already
// aborted before the API call site is reached.
fetch: vi.fn(async () => {
fetchCalls++;
return new Response(
JSON.stringify({
choices: [
{ index: 0, message: { role: "assistant", content: "late" }, finish_reason: "stop" },
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as any,
retry: { maxAttempts: 1 },
});
registerSubagentTool(parent, { client });
const ctrl = new AbortController();
ctrl.abort(); // already aborted before dispatch is even called
const out = await parent.dispatch("spawn_subagent", JSON.stringify({ task: "x" }), {
signal: ctrl.signal,
});
const parsed = JSON.parse(out);
expect(parsed.success).toBe(false);
expect(fetchCalls).toBe(0);
});
});
describe("forkRegistryExcluding", () => {
+96
View File
@@ -0,0 +1,96 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToolRegistry } from "../src/tools.js";
import { WorkspaceConfirmationError, registerWorkspaceTool } from "../src/tools/workspace.js";
describe("change_workspace tool", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-workspace-tool-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("registers a tool named change_workspace", () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
expect(reg.has("change_workspace")).toBe(true);
});
it("throws WorkspaceConfirmationError with the resolved absolute path on a valid directory", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", { path: tmp });
// ToolRegistry.dispatch serializes thrown errors into a JSON
// result with the error name + message. The model sees this; the
// App detects the marker and mounts the modal.
expect(out).toContain("WorkspaceConfirmationError");
// Path appears inside a JSON string, so backslashes (Windows) are
// doubled. Parse the result and compare against the canonical
// form rather than substring-checking the raw JSON.
const parsed = JSON.parse(out) as { error: string };
expect(parsed.error).toContain(tmp);
});
it("rejects empty / missing path with a normal error (no confirmation)", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out1 = await reg.dispatch("change_workspace", {});
expect(out1).toContain("non-empty string");
expect(out1).not.toContain("WorkspaceConfirmationError");
const out2 = await reg.dispatch("change_workspace", { path: "" });
expect(out2).toContain("non-empty string");
});
it("rejects a non-existent path", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", {
path: join(tmp, "does", "not", "exist"),
});
expect(out).toContain("does not exist");
expect(out).not.toContain("WorkspaceConfirmationError");
});
it("rejects a path that exists but is a regular file", async () => {
const file = join(tmp, "marker.txt");
writeFileSync(file, "hi");
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", { path: file });
expect(out).toContain("not a directory");
expect(out).not.toContain("WorkspaceConfirmationError");
});
it("never performs an actual switch — only ever throws confirmation", () => {
// The tool fn must be a pure validator → confirmation-error
// pump. App.tsx owns the actual cwd swap. This test pins the
// contract so a future "let's just switch directly" refactor
// gets caught.
const e = new WorkspaceConfirmationError("/some/abs/path");
expect(e.name).toBe("WorkspaceConfirmationError");
expect(e.path).toBe("/some/abs/path");
expect(e.message).toMatch(/needs the user's approval/);
expect(e.message).toMatch(/STOP calling tools/);
});
it("expands a leading ~ to the home directory when one is set", async () => {
// We can't assume HOME points at our tmp dir, but we CAN assert
// that `~` triggers expansion (the resulting absolute path won't
// equal `~/...` literally and the validator will run against the
// expanded form). Use a known-bad subpath so the validator
// surfaces a clear "does not exist" rather than passing through.
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", {
path: "~/__reasonix_definitely_not_a_real_dir_xyz__",
});
// The error message must contain the resolved absolute path,
// not the literal `~` prefix — otherwise expansion didn't happen.
expect(out).not.toContain("~");
expect(out).toContain("does not exist");
});
});