mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c594ad71f7 | ||
|
|
23c81e6f11 | ||
|
|
0d0cdfc54a | ||
|
|
60c598527d | ||
|
|
494c62ef60 | ||
|
|
c43ada74e2 | ||
|
|
18b8d0d8df | ||
|
|
855b91c7ed | ||
|
|
efcab7d141 | ||
|
|
ca1406d864 | ||
|
|
477e710ad6 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
@@ -14,9 +18,10 @@
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
[](./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
@@ -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
|
||||
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
[](https://github.com/esengine/reasonix/actions/workflows/ci.yml)
|
||||
[](./LICENSE)
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
[](./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
|
||||
Whitespace-only changes.
@@ -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
@@ -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
@@ -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 && 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</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 <spec> <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 <fast|smart|max> <span class="hash"># <span data-i18n="cli.f.preset">model + harvest + branch bundle</span></span>
|
||||
--model <id> <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 <name> <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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 更好。',
|
||||
};
|
||||
@@ -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$/, ""));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(/还没有索引/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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("你好");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user