Compare commits

...
1 Commits
14 changed files with 1307 additions and 220 deletions

No files matched your search

+83
View File
@@ -3,6 +3,89 @@
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.4.15] — 2026-04-22
**Headline:** Web search + fetch tools (on by default, zero
configuration) plus real cursor editing in the prompt box (←/→,
Backspace/Delete mid-string, multi-line ↑/↓ navigation).
### Fixed
- **PromptInput was append-only** — cursor was always pinned to
the end of the buffer, so the only way to fix a typo was
backspacing back through everything after it. Now:
- `←` / `→` move the cursor one column (clamped to buffer).
- `↑` / `↓` move across lines in a multi-line buffer, preserving
column when possible, clamping when the target line is shorter.
- `Ctrl+A` / `Ctrl+E` jump to start / end of the current line.
- `Backspace` deletes the char before the cursor; `Delete`
deletes the char under the cursor.
- Printable input inserts at the cursor (including multi-char
paste bursts).
- `Shift+Enter` / `Ctrl+J` insert a newline at the cursor.
- **History recall no longer steals arrow keys from mid-edit.**
`↑` / `↓` only trigger prior-prompt recall when the buffer is
empty. A non-empty buffer keeps the arrows for cursor motion so
typed text isn't clobbered.
### Added
Web search + fetch tools are registered by default on `reasonix
chat` and `reasonix code`. The model calls `web_search` /
`web_fetch` on its own whenever a question needs fresher info than
its training data. Backed by **Mojeek**'s public search page — no
API key, no signup. Same Cache-First + repair + context-safety
plumbing as every other tool.
Implementation note: the first cut of this feature used DuckDuckGo,
but a live probe from the dev machine confirmed DDG now serves
HTTP 202 anti-bot pages for every unauthenticated POST regardless
of UA. Mojeek is an independent-index engine that's been stable
against the same probe (3/3 success on three queries spaced 3s
apart). Real-browser `User-Agent` string avoids Mojeek's
fast-path scraper filter.
- **`src/tools/web.ts`** — two functions + one registration helper:
- `webSearch(query, opts?)` — fetches DDG's HTML endpoint, parses
ranked results (title + url + snippet). `topK` is clamped to
[1, 10]. Parser decodes DDG's `uddg=<url>` redirect wrapper and
common HTML entities.
- `webFetch(url, opts?)` — HTTP GET + HTML-to-text extraction
(scripts/styles/nav/footer/aside/svg stripped, paragraph breaks
preserved, entities decoded). 15s timeout, 32k-char cap (matches
tool-result budget), forwards caller's AbortSignal so Esc during
a long fetch is honored.
- `registerWebTools(registry, opts?)` — registers both as
ToolRegistry entries the model can invoke. Tool descriptions
guide the model to call search whenever training data might be
stale.
- **`ReasonixConfig.search`** + **`searchEnabled()`** — a simple
boolean. Default on. Turn off with `search: false` in config or
`REASONIX_SEARCH=off|false|0` in env. No API keys, no provider
picker — one switch.
- **Auto-registered in chat/code.** `reasonix chat` and
`reasonix code` register `web_search` + `web_fetch` by default.
Zero setup: after the normal wizard, the model can already reach
the web.
### Tests (+18, suite 444→462)
- `tests/web-tools.test.ts` (+13) — htmlToText strips
scripts/styles/nav/footer + decodes entities + collapses
whitespace; `parseDuckDuckGoResults` decodes redirect URLs + entities
+ returns empty on unexpected markup; `webSearch` hits the DDG
endpoint with a browsery UA, respects topK, clamps to [1, 10],
throws on non-2xx; `formatSearchResults` renders the expected
layout; `registerWebTools` registers both verbs; `web_fetch` refuses
non-http(s) URLs; `webFetch` extracts title + body, truncates at
the cap with a visible marker, surfaces 404s.
- `tests/config.test.ts` (+5) — `searchEnabled` defaults to true;
honors `search: false` in file; honors `REASONIX_SEARCH=off|false|0`;
stays true for unrelated env values; env off beats config true.
---
## [0.4.14] — 2026-04-22
**Headline:** Render-load reductions for Windows terminals where
+66
View File
@@ -322,6 +322,72 @@ don't need this opt-out.
---
## Web search — on by default
The model has two web tools the moment you launch: `web_search` and
`web_fetch`. No flag, no API key, no signup. When you ask about
something the model wasn't trained on (new releases, current events,
obscure APIs), it decides to call `web_search` on its own; if a
snippet isn't enough it follows up with `web_fetch`.
```
you › Flutter 3.19 新加了什么?
assistant
▸ tool<web_search> → query: "Flutter 3.19 new features"
▸ tool<web_fetch> → https://docs.flutter.dev/release/3-19
▸ 3.19 主要新增了 …
```
Backed by **Mojeek**'s public search page — an independent web index,
no API key, no signup, bot-friendly. Coverage on niche or very recent
queries can be thinner than Google/Bing, but it's reliable from
scripts and doesn't gate on cookies or sessions. (DDG was the original
backend but it started serving anti-bot pages in 2026.)
**Turn it off** (offline mode / privacy / CI):
```json
// ~/.reasonix/config.json
{ "apiKey": "sk-…", "search": false }
```
```bash
# Or one env var (wins over config):
REASONIX_SEARCH=off npx reasonix
```
**Bring your own provider** (Kagi, SearXNG, Serper, an internal
cache) — implement the two tools however you want and register them
manually:
```ts
import { ToolRegistry } from "reasonix";
// Register your own `web_search` / `web_fetch` on a ToolRegistry,
// then pass it to CacheFirstLoop (or `reasonix chat --no-config`
// with seedTools via library API).
```
Inside the session:
```
reasonix › Flutter 3.19 引入了什么新的 Navigator API?
assistant
▸ tool<web_search> → query: "Flutter 3.19 new Navigator API"
answer: Flutter 3.19 introduces the NavigatorObserver changes …
1. Flutter 3.19 Release Notes — https://docs.flutter.dev/…
2. What's new in Flutter 3.19 — https://medium.com/…
▸ tool<web_fetch> → https://docs.flutter.dev/release/release-notes/3-19-0
(full page text, clipped at 32k)
▸ 3.19 新增了 …
```
For advanced / self-hosted search (Kagi, SearXNG, internal caches)
implement the `WebSearchProvider` interface and call
`registerWebTools(registry, { provider })` yourself, or bridge an
existing MCP search server via `--mcp`.
---
## MCP — bring your own tools
Any [MCP](https://spec.modelcontextprotocol.io/) server works. Wizard
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.4.14",
"version": "0.4.15",
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
"type": "module",
"bin": {
+10 -1
View File
@@ -1,6 +1,6 @@
import { render } from "ink";
import React, { useState } from "react";
import { loadApiKey } from "../../config.js";
import { loadApiKey, searchEnabled } from "../../config.js";
import { loadDotenv } from "../../env.js";
import { McpClient } from "../../mcp/client.js";
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
@@ -9,6 +9,7 @@ import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { ToolRegistry } from "../../tools.js";
import { registerWebTools } from "../../tools/web.js";
import { App } from "../ui/App.js";
import { Setup } from "../ui/Setup.js";
import type { McpServerSummary } from "../ui/slash.js";
@@ -189,6 +190,14 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
}
const mcpSpecs = successfulSpecs;
// Register web search/fetch tools unless explicitly disabled. DDG
// backs them with no key required; the model invokes them whenever
// a question needs info fresher than its training data.
if (searchEnabled()) {
if (!tools) tools = new ToolRegistry();
registerWebTools(tools);
}
const { waitUntilExit } = render(
<Root
initialKey={initialKey}
+21 -15
View File
@@ -328,21 +328,27 @@ export function App({
}
}
// Outside slash mode: ↑/↓ recall prior prompts.
const hist = promptHistory.current;
if (key.upArrow) {
if (hist.length === 0) return;
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
historyCursor.current = nextCursor;
setInput(hist[hist.length - 1 - nextCursor] ?? "");
return;
}
if (key.downArrow) {
if (historyCursor.current < 0) return;
const nextCursor = historyCursor.current - 1;
historyCursor.current = nextCursor;
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
return;
// Outside slash mode: ↑/↓ recall prior prompts — but ONLY when the
// buffer is empty. A non-empty buffer hands those keys to
// PromptInput for cursor movement (multi-line navigation, or a
// no-op on single-line) so the user doesn't accidentally clobber
// typed text with a recalled prompt.
if (input.length === 0) {
const hist = promptHistory.current;
if (key.upArrow) {
if (hist.length === 0) return;
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
historyCursor.current = nextCursor;
setInput(hist[hist.length - 1 - nextCursor] ?? "");
return;
}
if (key.downArrow) {
if (historyCursor.current < 0) return;
const nextCursor = historyCursor.current - 1;
historyCursor.current = nextCursor;
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
return;
}
}
});
+12 -4
View File
@@ -323,17 +323,25 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
) : null}
{tail ? (
<Text dimColor>▸ {tail}</Text>
) : preFirstByte ? (
<Text dimColor italic>
{" connection open, first byte typically in 5-60s depending on model + load"}
</Text>
) : reasoningOnly ? (
<Text color="yellow" dimColor>
{
" R1 is thinking before it speaks — body text starts when reasoning completes (typically 20-90s)."
}
</Text>
) : (
<Text dimColor italic>
{" connection open, first byte typically in 5-60s depending on model + load"}
) : toolCallOnly ? (
<Text color="magenta" dimColor>
{" tool-call arguments streaming — the model is about to dispatch a tool"}
</Text>
)}
) : event.reasoning ? (
<Text color="yellow" dimColor>
{" R1 still reasoning — body text or tool call arrives when thinking completes"}
</Text>
) : null}
</Box>
);
}
+89 -37
View File
@@ -1,6 +1,6 @@
import { Box, Text, useInput } from "ink";
import React from "react";
import { type MultilineKey, processMultilineKey } from "./multiline-keys.js";
import React, { useRef, useState } from "react";
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
import { useTick } from "./ticker.js";
export interface PromptInputProps {
@@ -12,19 +12,16 @@ export interface PromptInputProps {
}
/**
* Input box with multi-line composition support. Behaves like the
* old single-line prompt by default, but:
* - Ctrl+J inserts a newline (universal across terminals).
* - Shift+Enter inserts a newline (only on terminals that report
* the modifier via CSI-u — many don't).
* - `\<Enter>` at end of line acts as bash-style continuation —
* always works, even on terminals without modifier reporting.
* - Pasted multi-line text lands as-is instead of submitting on
* the first '\r'.
* Input box with real cursor support. ←/→ move one column, ↑/↓ move
* across lines in multi-line buffers, Ctrl+A / Ctrl+E jump to
* start/end of the current line. Backspace deletes before cursor,
* Delete deletes under cursor. Multi-line composition via Ctrl+J,
* Shift+Enter, or bash-style `\<Enter>`.
*
* No mid-string insertion cursor — edits happen at the end, like
* a readline prompt in raw mode. Simple, predictable, enough for
* 95% of prompts and any code paste.
* Cursor state lives locally. When the parent replaces `value` out
* of band (history recall, slash completion, setup wizard) the
* cursor jumps to end; the `lastLocalValueRef` guards distinguishes
* that case from our own edits.
*/
export function PromptInput({
value,
@@ -33,15 +30,29 @@ export function PromptInput({
disabled,
placeholder,
}: PromptInputProps) {
// Blink from the shared ticker (TICK_MS ≈ 120ms) scaled by 4 so the
// visible on/off toggles land around 500ms — standard cursor blink.
// When disabled the cursor is hidden entirely.
const [cursor, setCursor] = useState(value.length);
// Tracks the last `value` we ourselves produced via onChange. If the
// incoming `value` prop diverges from this, the parent (or some other
// source) replaced the buffer — we reset the cursor to end.
const lastLocalValueRef = useRef(value);
if (value !== lastLocalValueRef.current) {
lastLocalValueRef.current = value;
if (cursor !== value.length) {
// Conditional setState during render is the "derived state" pattern;
// React schedules the re-render and the else branch of the `if`
// prevents infinite loops.
setCursor(value.length);
}
}
// Shared ticker drives the cursor blink. Dividing the tick by 4 lands
// the visible on/off cycle around 480ms — standard cursor cadence.
const tick = useTick();
const showCursor = disabled ? false : Math.floor(tick / 4) % 2 === 0;
useInput(
(input, key) => {
const keyEvent: MultilineKey = {
const ke: MultilineKey = {
input,
return: key.return,
shift: key.shift,
@@ -58,8 +69,14 @@ export function PromptInput({
pageUp: key.pageUp,
pageDown: key.pageDown,
};
const action = processMultilineKey(value, keyEvent);
if (action.next !== null) onChange(action.next);
const action = processMultilineKey(value, cursor, ke);
if (action.next !== null) {
lastLocalValueRef.current = action.next;
onChange(action.next);
}
if (action.cursor !== null) {
setCursor(action.cursor);
}
if (action.submit) onSubmit(action.submitValue ?? value);
},
{ isActive: !disabled },
@@ -71,17 +88,14 @@ export function PromptInput({
const lines = value.length > 0 ? value.split("\n") : [""];
const borderColor = disabled ? "gray" : "cyan";
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
return (
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="column">
{lines.map((line, i) => {
const isLast = i === lines.length - 1;
const isFirst = i === 0;
const showPlaceholder = isFirst && value.length === 0;
// Line content never survives a re-keying here: the value buffer
// fully re-renders on every keystroke, so the array index IS the
// stable identity. Biome's rule targets list reordering, which
// doesn't apply to an append-only splits-by-newline view.
const isCursorLine = i === cursorLine;
return (
// biome-ignore lint/suspicious/noArrayIndexKey: stable by construction — lines are derived from `value.split("\n")` and never reordered
<Box key={i}>
@@ -92,21 +106,59 @@ export function PromptInput({
) : (
<Text dimColor>{" "}</Text>
)}
{/* When showing the placeholder, the cursor is at position 0 —
put it BEFORE the dimmed hint text so it visually matches
"you're about to type here," not "you typed the placeholder."
When showing real content, the cursor follows the last char
of the last line (append-only edit model). */}
{showPlaceholder && isLast && !disabled ? (
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
) : null}
{showPlaceholder ? <Text dimColor>{effectivePlaceholder}</Text> : <Text>{line}</Text>}
{!showPlaceholder && isLast && !disabled ? (
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
) : null}
{showPlaceholder ? (
<>
{isCursorLine && !disabled ? (
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
) : null}
<Text dimColor>{effectivePlaceholder}</Text>
</>
) : isCursorLine && !disabled ? (
<LineWithCursor
line={line}
col={cursorCol}
showCursor={showCursor}
borderColor={borderColor}
/>
) : (
<Text>{line}</Text>
)}
</Box>
);
})}
</Box>
);
}
function LineWithCursor({
line,
col,
showCursor,
borderColor,
}: {
line: string;
col: number;
showCursor: boolean;
borderColor: "cyan" | "gray";
}) {
const before = line.slice(0, col);
const atCursor = line.slice(col, col + 1);
const after = line.slice(col + 1);
if (atCursor.length === 0) {
// Cursor sits past the last char of this line (end-of-line). Render
// a trailing block so the user sees where they're typing next.
return (
<>
<Text>{before}</Text>
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
</>
);
}
return (
<>
<Text>{before}</Text>
<Text inverse={showCursor}>{atCursor}</Text>
<Text>{after}</Text>
</>
);
}
+153 -75
View File
@@ -1,30 +1,34 @@
/**
* Pure keystroke → action reducer for MultilineInput.
* Pure keystroke → action reducer for PromptInput.
*
* Kept separate from the React component so the keyboard
* semantics are easy to unit-test. MultilineInput is otherwise
* a thin Ink wrapper that threads its `useInput` callback
* through this function and applies the result.
* Kept separate from the React component so the keyboard semantics
* are easy to unit-test. The component threads `useInput` through
* this function and applies the returned action.
*
* Terminal reality check: on most terminals, pressing Shift+Enter
* sends the exact same byte as plain Enter — the modifier is lost.
* That's why the reliable newline insertion path is Ctrl+J (which
* really is LF, distinct from CR), with Shift+Enter only working
* on terminals that opt into CSI-u modifier encoding. The `\<Enter>`
* bash-style continuation is the portable fallback.
* Edit model:
* - Full cursor support. ←/→ move one column. ↑/↓ move across
* lines in a multi-line buffer (column preserved when possible).
* Ctrl+A / Ctrl+E jump to start / end of the current line.
* - Backspace deletes the char before the cursor; Delete deletes
* the char under the cursor.
* - Printable chars (including multi-char paste bursts) insert
* at the cursor.
* - Enter submits unless Shift is held (newline), the line ends
* with '\' and cursor is at end (bash-style continuation), or
* the input is Ctrl+J (LF, terminal-universal newline).
* - Parent owns Tab, Esc, PageUp/Down (slash-complete, abort,
* unused). Arrow keys are split: empty buffer → parent (history
* recall); non-empty → child (cursor movement).
*/
export interface MultilineKey {
/** Printable character(s) delivered by the key event, may be empty. */
input: string;
/** Modifier + named-key flags as Ink reports them. */
return?: boolean;
shift?: boolean;
ctrl?: boolean;
meta?: boolean;
backspace?: boolean;
delete?: boolean;
/** Navigation/editing keys owned by the parent App (slash-nav, history). */
tab?: boolean;
upArrow?: boolean;
downArrow?: boolean;
@@ -36,87 +40,161 @@ export interface MultilineKey {
}
export interface MultilineAction {
/** New value to set on the text buffer. `null` = unchanged. */
/** New buffer value. `null` = unchanged. */
next: string | null;
/** When `true`, the caller should fire `onSubmit(next ?? value)`. */
/** New cursor position (0..value.length). `null` = unchanged. */
cursor: number | null;
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
/** When submitting, which value to submit (after any final trims). */
submitValue?: string;
}
const BACKSLASH_SUFFIX = /\\$/;
/**
* Decide what to do with a single keystroke against the current buffer.
*
* Decisions:
* Ctrl+J → insert '\n' (terminal-universal newline)
* Shift+Return → insert '\n' (works where CSI-u is on)
* Enter with "\" end → replace trailing '\' with '\n' (bash continuation)
* Enter → submit the current buffer
* Backspace/Delete → drop one char from the end (cursor-at-end model)
* Printable char(s) → append at the end
*
* We don't model an insertion cursor inside the string: always-at-end
* keeps the reducer tiny and matches how readline-in-raw-mode feels. The
* user can still edit by backspacing — for deep edits, exit and compose
* in $EDITOR.
*/
export function processMultilineKey(value: string, key: MultilineKey): MultilineAction {
// Parent App owns these keys: Tab for slash auto-complete, arrows
// for slash-nav/history, Esc for abort. Bowing out here keeps the
// text buffer from eating a stray "\t" or arrow escape sequence
// when both the parent and child useInput fire on the same event.
if (
key.tab ||
key.upArrow ||
key.downArrow ||
key.leftArrow ||
key.rightArrow ||
key.escape ||
key.pageUp ||
key.pageDown
) {
return { next: null, submit: false };
const NOOP: MultilineAction = { next: null, cursor: null, submit: false };
export function processMultilineKey(
value: string,
cursor: number,
key: MultilineKey,
): MultilineAction {
// Parent-owned keys: Tab (slash-complete), Esc (abort), PageUp/Down.
if (key.tab || key.escape || key.pageUp || key.pageDown) {
return NOOP;
}
// Reliable newline: Ctrl+J arrives as literal '\n' in key.input on
// every terminal we've checked. We also accept key.ctrl && input=='j'
// as a belt-and-braces match against terminals that normalize the
// control-char into its ASCII letter form.
// Empty buffer + ↑/↓ → parent handles history recall.
if (value.length === 0 && (key.upArrow || key.downArrow)) {
return NOOP;
}
// Cursor motion.
if (key.leftArrow) {
return { next: null, cursor: Math.max(0, cursor - 1), submit: false };
}
if (key.rightArrow) {
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
}
if (key.upArrow) {
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
// Emacs-style line jumps (universal across terminals; Home/End aren't
// reliably reported by Ink so we don't depend on them).
if (key.ctrl && key.input === "a") {
return { next: null, cursor: startOfLine(value, cursor), submit: false };
}
if (key.ctrl && key.input === "e") {
return { next: null, cursor: endOfLine(value, cursor), submit: false };
}
// Newline: Ctrl+J (LF literal) or ctrl+'j' normalized form.
if (key.input === "\n" || (key.ctrl && key.input === "j")) {
return { next: `${value}\n`, submit: false };
return insertAt(value, cursor, "\n");
}
if (key.return) {
// Shift+Return → newline (when terminal reports the modifier).
if (key.shift) {
return { next: `${value}\n`, submit: false };
if (key.shift) return insertAt(value, cursor, "\n");
// Bash-style line continuation: trailing '\' + Enter (only when the
// cursor sits at end-of-buffer, so a stray '\' mid-line doesn't
// trigger it).
if (cursor === value.length && BACKSLASH_SUFFIX.test(value)) {
const replaced = `${value.slice(0, -1)}\n`;
return { next: replaced, cursor: replaced.length, submit: false };
}
// Bash-style line continuation: trailing '\' + Enter.
if (BACKSLASH_SUFFIX.test(value)) {
return { next: `${value.slice(0, -1)}\n`, submit: false };
}
// Plain Enter → submit.
return { next: null, submit: true, submitValue: value };
return { next: null, cursor: null, submit: true, submitValue: value };
}
if (key.backspace || key.delete) {
if (value.length === 0) return { next: null, submit: false };
return { next: value.slice(0, -1), submit: false };
if (key.backspace) {
if (cursor === 0) return NOOP;
return {
next: value.slice(0, cursor - 1) + value.slice(cursor),
cursor: cursor - 1,
submit: false,
};
}
if (key.delete) {
if (cursor === value.length) return NOOP;
return {
next: value.slice(0, cursor) + value.slice(cursor + 1),
cursor,
submit: false,
};
}
// Ignore bare modifier keys (ctrl/meta without accompanying printable).
if ((key.ctrl || key.meta) && key.input.length === 0) {
return { next: null, submit: false };
// Bare modifier events (Ctrl/Meta with no printable) and unhandled
// Ctrl-<letter> chords are dropped so a stray Ctrl+L doesn't insert "l".
if ((key.ctrl || key.meta) && key.input.length === 0) return NOOP;
if (key.ctrl || key.meta) return NOOP;
// Printable input (may be a multi-char paste; pasted newlines land
// inside the buffer rather than triggering submit on the first line).
if (key.input.length > 0) {
return insertAt(value, cursor, key.input);
}
// Printable input (may be a multi-char paste burst — accept as-is;
// pasted newlines become part of the buffer rather than triggering
// submit on the first line).
if (key.input.length > 0 && !key.ctrl && !key.meta) {
return { next: value + key.input, submit: false };
return NOOP;
}
function insertAt(value: string, cursor: number, insert: string): MultilineAction {
return {
next: value.slice(0, cursor) + insert + value.slice(cursor),
cursor: cursor + insert.length,
submit: false,
};
}
/**
* Line + column of a cursor inside a buffer. Exported because the
* renderer needs the same mapping for drawing the cursor block on the
* right line.
*/
export function lineAndColumn(value: string, cursor: number): { line: number; col: number } {
let line = 0;
let col = 0;
const n = Math.min(cursor, value.length);
for (let i = 0; i < n; i++) {
if (value[i] === "\n") {
line++;
col = 0;
} else {
col++;
}
}
return { line, col };
}
function startOfLine(value: string, cursor: number): number {
return value.lastIndexOf("\n", cursor - 1) + 1;
}
function endOfLine(value: string, cursor: number): number {
const nl = value.indexOf("\n", cursor);
return nl === -1 ? value.length : nl;
}
function moveCursorUp(value: string, cursor: number): number {
const curStart = startOfLine(value, cursor);
if (curStart === 0) return cursor; // already on the first line
const col = cursor - curStart;
const prevEnd = curStart - 1; // the '\n' between the two lines
const prevStart = value.lastIndexOf("\n", prevEnd - 1) + 1;
const prevLen = prevEnd - prevStart;
return prevStart + Math.min(col, prevLen);
}
return { next: null, submit: false };
function moveCursorDown(value: string, cursor: number): number {
const nextNl = value.indexOf("\n", cursor);
if (nextNl === -1) return cursor; // already on the last line
const curStart = startOfLine(value, cursor);
const col = cursor - curStart;
const nextStart = nextNl + 1;
const followingNl = value.indexOf("\n", nextStart);
const nextLen = (followingNl === -1 ? value.length : followingNl) - nextStart;
return nextStart + Math.min(col, nextLen);
}
+19
View File
@@ -44,6 +44,12 @@ export interface ReasonixConfig {
session?: string | null;
/** Marks that `reasonix setup` has completed at least once. */
setupCompleted?: boolean;
/**
* Whether `web_search` + `web_fetch` tools are registered. Default:
* enabled (no key required — backed by DuckDuckGo's public HTML
* endpoint). Set to `false` to keep the session offline.
*/
search?: boolean;
}
export function defaultConfigPath(): string {
@@ -78,6 +84,19 @@ export function loadApiKey(path: string = defaultConfigPath()): string | undefin
return readConfig(path).apiKey;
}
/**
* Resolve whether web-search tools should be registered. Default: on.
* Env `REASONIX_SEARCH=off` or config `search: false` turns it off.
* Any other value falls through to enabled.
*/
export function searchEnabled(path: string = defaultConfigPath()): boolean {
const env = process.env.REASONIX_SEARCH;
if (env === "off" || env === "false" || env === "0") return false;
const cfg = readConfig(path).search;
if (cfg === false) return false;
return true;
}
export function saveApiKey(key: string, path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
cfg.apiKey = key.trim();
+16 -1
View File
@@ -33,6 +33,21 @@ export { ToolRegistry } from "./tools.js";
export type { ToolDefinition, ToolCallContext } from "./tools.js";
export { registerFilesystemTools } from "./tools/filesystem.js";
export type { FilesystemToolsOptions } from "./tools/filesystem.js";
export {
formatSearchResults,
htmlToText,
parseMojeekResults,
registerWebTools,
webFetch,
webSearch,
} from "./tools/web.js";
export type {
PageContent,
SearchResult,
WebFetchOptions,
WebSearchOptions,
WebToolsOptions,
} from "./tools/web.js";
export {
SessionStats,
@@ -186,4 +201,4 @@ export type {
JSONSchema,
} from "./types.js";
export const VERSION = "0.4.14";
export const VERSION = "0.4.15";
+299
View File
@@ -0,0 +1,299 @@
/**
* Built-in web search + fetch tools.
*
* - `web_search(query, topK?)` — Mojeek's public search page. No API
* key, no signup. We originally shipped this backed by DuckDuckGo's
* HTML endpoint, but DDG started serving anti-bot interstitials
* (HTTP 202 with a challenge page) for every unauthenticated POST.
* Mojeek runs its own independent index, is bot-friendly, and
* returns parseable HTML.
* - `web_fetch(url)` — HTTP GET + naïve HTML-to-text extraction.
*
* Both are registered by default on `reasonix chat` / `reasonix code`;
* set `search: false` in config (or `REASONIX_SEARCH=off`) to turn
* them off. The model decides when to call them based on the query —
* no slash command required.
*/
import type { ToolRegistry } from "../tools.js";
export interface SearchResult {
title: string;
url: string;
snippet: string;
}
export interface PageContent {
url: string;
title?: string;
text: string;
/** True when the extracted text was clipped to fit the cap. */
truncated: boolean;
}
export interface WebFetchOptions {
/** Max bytes of extracted text. Defaults to 32_000 to match tool-result cap. */
maxChars?: number;
/** Timeout in ms. Defaults to 15_000. */
timeoutMs?: number;
signal?: AbortSignal;
}
export interface WebSearchOptions {
topK?: number;
signal?: AbortSignal;
}
const DEFAULT_FETCH_MAX_CHARS = 32_000;
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
const DEFAULT_TOPK = 5;
// Real-browser UA. Servers like Mojeek are bot-friendly but still gate
// obvious scraper UAs; a stock Chrome string avoids the fast-path block.
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const MOJEEK_ENDPOINT = "https://www.mojeek.com/search";
/**
* Search the public web via Mojeek. Returns up to `topK` ranked
* results with title, url, snippet.
*
* Mojeek is an independent index (not a Google/Bing front-end) which
* means coverage on niche or very recent topics can be thinner, but
* it's reliable from scripts and doesn't gate on cookies or sessions.
* If the response has 0 results we distinguish "truly empty" from
* "layout changed or blocked" so the caller isn't left guessing.
*/
export async function webSearch(
query: string,
opts: WebSearchOptions = {},
): Promise<SearchResult[]> {
const topK = Math.max(1, Math.min(10, opts.topK ?? DEFAULT_TOPK));
const resp = await fetch(`${MOJEEK_ENDPOINT}?q=${encodeURIComponent(query)}`, {
headers: {
"User-Agent": USER_AGENT,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language": "en-US,en;q=0.9",
},
signal: opts.signal,
redirect: "follow",
});
if (!resp.ok) throw new Error(`web_search ${resp.status}`);
const html = await resp.text();
const results = parseMojeekResults(html).slice(0, topK);
if (results.length === 0) {
if (/no results found|did not match any documents/i.test(html)) return [];
if (/captcha|verify you are human|access denied|forbidden/i.test(html)) {
throw new Error("web_search: Mojeek anti-bot page — rate-limited or blocked");
}
throw new Error(
`web_search: 0 results but response doesn't look like a real empty page (${html.length} chars, first 120: ${html.slice(0, 120).replace(/\s+/g, " ")})`,
);
}
return results;
}
/**
* Extract results from a Mojeek search page.
*
* Mojeek's stable shape (as of April 2026):
* <a … class="ob" href="URL"> … breadcrumb … </a>
* <h2><a class="title" href="URL">Title</a></h2>
* <p class="s">snippet text …</p>
*
* We do two tolerant passes — title anchors, then snippet paragraphs —
* and pair them positionally. Attribute order inside a tag varies
* between versions, so each pass captures the whole element and we
* re-extract href / inner text with a second regex. Exported for
* unit testing against a fixture.
*/
export function parseMojeekResults(html: string): SearchResult[] {
const titles: string[] = [];
const titleAnchorRe = /<a\b[^>]*\bclass="title"[^>]*>[\s\S]*?<\/a>/g;
let m: RegExpExecArray | null;
while (true) {
m = titleAnchorRe.exec(html);
if (m === null) break;
titles.push(m[0]);
}
const snippets: string[] = [];
const snippetRe = /<p\b[^>]*\bclass="s"[^>]*>([\s\S]*?)<\/p>/g;
while (true) {
m = snippetRe.exec(html);
if (m === null) break;
snippets.push(m[1] ?? "");
}
const hrefRe = /href="([^"]+)"/;
const innerRe = /<a\b[^>]*>([\s\S]*?)<\/a>/;
const results: SearchResult[] = [];
for (let i = 0; i < titles.length; i++) {
const anchor = titles[i]!;
const hrefMatch = anchor.match(hrefRe);
const innerMatch = anchor.match(innerRe);
if (!hrefMatch?.[1]) continue;
results.push({
title: decodeHtmlEntities(stripHtml(innerMatch?.[1] ?? "")).trim(),
url: hrefMatch[1],
snippet: decodeHtmlEntities(stripHtml(snippets[i] ?? ""))
.replace(/\s+/g, " ")
.trim(),
});
}
return results;
}
/**
* Download a URL, strip HTML down to readable text, return it. Times
* out at 15s, caps extracted text at 32k chars to fit the tool-result
* budget.
*/
export async function webFetch(url: string, opts: WebFetchOptions = {}): Promise<PageContent> {
const maxChars = opts.maxChars ?? DEFAULT_FETCH_MAX_CHARS;
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
// Forward the caller's abort too so an Esc during a long fetch is respected.
const cancel = () => ctl.abort();
opts.signal?.addEventListener("abort", cancel, { once: true });
let resp: Response;
try {
resp = await fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: "text/html,text/plain,*/*" },
signal: ctl.signal,
redirect: "follow",
});
} finally {
clearTimeout(timer);
opts.signal?.removeEventListener("abort", cancel);
}
if (!resp.ok) throw new Error(`web_fetch ${resp.status} for ${url}`);
const contentType = resp.headers.get("content-type") ?? "";
const raw = await resp.text();
const title = extractTitle(raw);
const text = contentType.includes("text/html") ? htmlToText(raw) : raw;
const truncated = text.length > maxChars;
const finalText = truncated
? `${text.slice(0, maxChars)}\n\n[… truncated ${text.length - maxChars} chars …]`
: text;
return { url, title, text: finalText, truncated };
}
/**
* Strip HTML to readable text. Removes scripts/styles/nav/footer/aside
* blocks first, then tags, then collapses whitespace. Not a Readability
* clone — purpose-built to keep the extracted text small enough for the
* tool-result budget while preserving paragraph breaks.
*/
export function htmlToText(html: string): string {
let s = html;
s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
s = s.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
s = s.replace(/<nav[\s\S]*?<\/nav>/gi, "");
s = s.replace(/<footer[\s\S]*?<\/footer>/gi, "");
s = s.replace(/<aside[\s\S]*?<\/aside>/gi, "");
s = s.replace(/<svg[\s\S]*?<\/svg>/gi, "");
// Preserve paragraph breaks by turning common block tags into newlines.
s = s.replace(/<\/?(p|div|br|h[1-6]|li|tr|section|article)\b[^>]*>/gi, "\n");
s = s.replace(/<[^>]+>/g, "");
s = decodeHtmlEntities(s);
s = s.replace(/[ \t]+/g, " ");
s = s.replace(/\n[ \t]+/g, "\n");
s = s.replace(/\n{3,}/g, "\n\n");
return s.trim();
}
function stripHtml(s: string): string {
return s.replace(/<[^>]+>/g, "");
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function extractTitle(html: string): string | undefined {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!m?.[1]) return undefined;
return m[1].replace(/\s+/g, " ").trim() || undefined;
}
export interface WebToolsOptions {
/** Default top-K for `web_search` when the model doesn't specify. */
defaultTopK?: number;
/** Byte cap for `web_fetch` extracted text. */
maxFetchChars?: number;
}
/**
* Register `web_search` + `web_fetch` on a ToolRegistry. The model
* invokes them automatically when a question needs current info —
* no slash command from the user is required.
*/
export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions = {}): ToolRegistry {
const defaultTopK = opts.defaultTopK ?? DEFAULT_TOPK;
const maxFetchChars = opts.maxFetchChars ?? DEFAULT_FETCH_MAX_CHARS;
registry.register({
name: "web_search",
description:
"Search the public web. Returns ranked results with title, url, and snippet. Use this when the question needs information more current than your training data, when you're unsure of a factual detail, or when the user asks about a specific webpage/library/release you haven't seen.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Natural-language search query." },
topK: {
type: "integer",
description: `Number of results to return (1..10). Default ${defaultTopK}.`,
},
},
required: ["query"],
},
fn: async (args: { query: string; topK?: number }, ctx) => {
const results = await webSearch(args.query, {
topK: args.topK ?? defaultTopK,
signal: ctx?.signal,
});
return formatSearchResults(args.query, results);
},
});
registry.register({
name: "web_fetch",
description:
"Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "Absolute http:// or https:// URL." },
},
required: ["url"],
},
fn: async (args: { url: string }, ctx) => {
if (!/^https?:\/\//i.test(args.url)) {
throw new Error("web_fetch: url must start with http:// or https://");
}
const page = await webFetch(args.url, { maxChars: maxFetchChars, signal: ctx?.signal });
const header = page.title ? `${page.title}\n${page.url}` : page.url;
return `${header}\n\n${page.text}`;
},
});
return registry;
}
export function formatSearchResults(query: string, results: SearchResult[]): string {
const lines: string[] = [`query: ${query}`, `\nresults (${results.length}):`];
results.forEach((r, i) => {
lines.push(`\n${i + 1}. ${r.title}`);
lines.push(` ${r.url}`);
if (r.snippet) lines.push(` ${r.snippet}`);
});
return lines.join("\n");
}
+39
View File
@@ -8,6 +8,7 @@ import {
readConfig,
redactKey,
saveApiKey,
searchEnabled,
writeConfig,
} from "../src/config.js";
@@ -15,12 +16,15 @@ describe("config", () => {
let dir: string;
let path: string;
const originalEnv = process.env.DEEPSEEK_API_KEY;
const originalSearch = process.env.REASONIX_SEARCH;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "reasonix-test-"));
path = join(dir, "config.json");
// biome-ignore lint/performance/noDelete: the string "undefined" leaks into process.env otherwise
delete process.env.DEEPSEEK_API_KEY;
// biome-ignore lint/performance/noDelete: same reason
delete process.env.REASONIX_SEARCH;
});
afterEach(() => {
@@ -31,6 +35,12 @@ describe("config", () => {
} else {
process.env.DEEPSEEK_API_KEY = originalEnv;
}
if (originalSearch === undefined) {
// biome-ignore lint/performance/noDelete: same reason
delete process.env.REASONIX_SEARCH;
} else {
process.env.REASONIX_SEARCH = originalSearch;
}
});
it("readConfig returns {} when file is missing", () => {
@@ -106,4 +116,33 @@ describe("config", () => {
const loaded = readConfig(path);
expect(loaded.session).toBeNull();
});
it("searchEnabled defaults to true with no config and no env", () => {
expect(searchEnabled(path)).toBe(true);
});
it("searchEnabled honours `search: false` in the config file", () => {
writeConfig({ apiKey: "sk-test123abcdefghijkl", search: false }, path);
expect(searchEnabled(path)).toBe(false);
});
it("searchEnabled honours REASONIX_SEARCH=off/false/0", () => {
process.env.REASONIX_SEARCH = "off";
expect(searchEnabled(path)).toBe(false);
process.env.REASONIX_SEARCH = "false";
expect(searchEnabled(path)).toBe(false);
process.env.REASONIX_SEARCH = "0";
expect(searchEnabled(path)).toBe(false);
});
it("searchEnabled stays true for unrelated env values", () => {
process.env.REASONIX_SEARCH = "on";
expect(searchEnabled(path)).toBe(true);
});
it("env off beats config true", () => {
writeConfig({ apiKey: "sk-test123abcdefghijkl", search: true }, path);
process.env.REASONIX_SEARCH = "off";
expect(searchEnabled(path)).toBe(false);
});
});
+207 -86
View File
@@ -1,112 +1,233 @@
import { describe, expect, it } from "vitest";
import { type MultilineKey, processMultilineKey } from "../src/cli/ui/multiline-keys.js";
import {
type MultilineKey,
lineAndColumn,
processMultilineKey,
} from "../src/cli/ui/multiline-keys.js";
function key(overrides: Partial<MultilineKey> = {}): MultilineKey {
return { input: "", ...overrides };
}
describe("processMultilineKey", () => {
it("appends printable input at the end", () => {
const r = processMultilineKey("hel", key({ input: "l" }));
expect(r).toEqual({ next: "hell", submit: false });
describe("processMultilineKey — inserts at cursor", () => {
it("inserts a printable char at the cursor (mid-string)", () => {
// "heo", cursor after "he" → insert "ll" in the middle
const r = processMultilineKey("heo", 2, key({ input: "ll" }));
expect(r.next).toBe("hello");
expect(r.cursor).toBe(4);
});
it("appends a multi-char paste burst as-is", () => {
const r = processMultilineKey("", key({ input: "line1\nline2" }));
expect(r.next).toBe("line1\nline2");
it("appends when cursor is at end", () => {
const r = processMultilineKey("hel", 3, key({ input: "l" }));
expect(r.next).toBe("hell");
expect(r.cursor).toBe(4);
});
it("inserts at position 0", () => {
const r = processMultilineKey("bc", 0, key({ input: "a" }));
expect(r.next).toBe("abc");
expect(r.cursor).toBe(1);
});
it("multi-char paste burst inserts as a block", () => {
const r = processMultilineKey("ab", 1, key({ input: "XYZ" }));
expect(r.next).toBe("aXYZb");
expect(r.cursor).toBe(4);
});
it("pasted newline lands inline (not a submit)", () => {
const r = processMultilineKey("a", 1, key({ input: "\nmore" }));
expect(r.next).toBe("a\nmore");
expect(r.submit).toBe(false);
});
});
it("Enter on a plain buffer submits", () => {
const r = processMultilineKey("hi", key({ return: true }));
describe("processMultilineKey — submit + newline", () => {
it("Enter submits by default", () => {
const r = processMultilineKey("hi", 2, key({ return: true }));
expect(r.submit).toBe(true);
expect(r.submitValue).toBe("hi");
expect(r.next).toBeNull();
});
it("Enter with trailing backslash converts to newline (bash-style continuation)", () => {
const r = processMultilineKey("line1\\", key({ return: true }));
it("Shift+Enter inserts a newline at the cursor", () => {
const r = processMultilineKey("abc", 1, key({ return: true, shift: true }));
expect(r.next).toBe("a\nbc");
expect(r.cursor).toBe(2);
expect(r.submit).toBe(false);
});
it("Ctrl+J inserts a newline at the cursor (ASCII LF form)", () => {
const r = processMultilineKey("abc", 2, key({ input: "\n" }));
expect(r.next).toBe("ab\nc");
expect(r.cursor).toBe(3);
});
it("Ctrl+J normalized as ctrl+'j' also inserts a newline", () => {
const r = processMultilineKey("abc", 2, key({ input: "j", ctrl: true }));
expect(r.next).toBe("ab\nc");
});
it("Enter with trailing \\\\ at end-of-buffer → bash continuation", () => {
const v = "line1\\";
const r = processMultilineKey(v, v.length, key({ return: true }));
expect(r.submit).toBe(false);
expect(r.next).toBe("line1\n");
expect(r.cursor).toBe(6);
});
it("Shift+Enter inserts a newline without submitting", () => {
const r = processMultilineKey("abc", key({ return: true, shift: true }));
expect(r.submit).toBe(false);
expect(r.next).toBe("abc\n");
});
it("Ctrl+J (ASCII LF) inserts a newline", () => {
const r = processMultilineKey("abc", key({ input: "\n" }));
expect(r.next).toBe("abc\n");
expect(r.submit).toBe(false);
});
it("Ctrl+J reported as ctrl+'j' (normalized form) also inserts a newline", () => {
const r = processMultilineKey("abc", key({ input: "j", ctrl: true }));
expect(r.next).toBe("abc\n");
expect(r.submit).toBe(false);
});
it("Backspace drops one char from the end", () => {
const r = processMultilineKey("abcd", key({ backspace: true }));
expect(r.next).toBe("abc");
});
it("Backspace on empty buffer is a no-op", () => {
const r = processMultilineKey("", key({ backspace: true }));
expect(r.next).toBeNull();
expect(r.submit).toBe(false);
});
it("Backspace can delete across newlines", () => {
const r = processMultilineKey("a\nb", key({ backspace: true }));
expect(r.next).toBe("a\n");
const r2 = processMultilineKey(r.next!, key({ backspace: true }));
expect(r2.next).toBe("a");
});
it("Delete behaves like Backspace (cursor-at-end model)", () => {
const r = processMultilineKey("xyz", key({ delete: true }));
expect(r.next).toBe("xy");
});
it("Tab is ignored (parent handles slash auto-complete)", () => {
const r = processMultilineKey("/he", key({ tab: true, input: "" }));
expect(r.next).toBeNull();
expect(r.submit).toBe(false);
});
it("Tab with '\\t' in input is still ignored", () => {
const r = processMultilineKey("x", key({ tab: true, input: "\t" }));
it("Enter with \\\\ mid-buffer (cursor not at end) does NOT trigger continuation", () => {
// User has "foo\\bar" and hits Enter with cursor after "foo\\" — that's
// a real edit, not a continuation marker. Submit instead.
const r = processMultilineKey("foo\\bar", 4, key({ return: true }));
expect(r.submit).toBe(true);
expect(r.next).toBeNull();
});
it("Arrow keys are ignored (parent handles slash-nav + history)", () => {
expect(processMultilineKey("x", key({ upArrow: true })).next).toBeNull();
expect(processMultilineKey("x", key({ downArrow: true })).next).toBeNull();
expect(processMultilineKey("x", key({ leftArrow: true })).next).toBeNull();
expect(processMultilineKey("x", key({ rightArrow: true })).next).toBeNull();
});
it("Escape is ignored (parent handles abort)", () => {
const r = processMultilineKey("x", key({ escape: true }));
expect(r.next).toBeNull();
});
it("Ctrl+<letter> that isn't j is dropped (no accidental text insert)", () => {
const r = processMultilineKey("x", key({ input: "c", ctrl: true }));
expect(r.next).toBeNull();
});
it("Meta (Alt) key events are dropped", () => {
const r = processMultilineKey("x", key({ input: "a", meta: true }));
expect(r.next).toBeNull();
});
it("Plain return on an empty buffer still submits (empty message case handled upstream)", () => {
const r = processMultilineKey("", key({ return: true }));
it("plain Enter on an empty buffer still submits", () => {
const r = processMultilineKey("", 0, key({ return: true }));
expect(r.submit).toBe(true);
expect(r.submitValue).toBe("");
});
});
describe("processMultilineKey — deletion", () => {
it("Backspace deletes the char BEFORE the cursor, cursor moves back", () => {
const r = processMultilineKey("abcd", 2, key({ backspace: true }));
expect(r.next).toBe("acd");
expect(r.cursor).toBe(1);
});
it("Backspace at cursor 0 is a no-op", () => {
const r = processMultilineKey("abc", 0, key({ backspace: true }));
expect(r.next).toBeNull();
expect(r.cursor).toBeNull();
});
it("Delete removes the char AT the cursor, cursor stays", () => {
const r = processMultilineKey("abcd", 1, key({ delete: true }));
expect(r.next).toBe("acd");
expect(r.cursor).toBe(1);
});
it("Delete at end-of-buffer is a no-op", () => {
const r = processMultilineKey("abc", 3, key({ delete: true }));
expect(r.next).toBeNull();
});
it("Backspace across a newline removes the newline", () => {
const r = processMultilineKey("a\nb", 2, key({ backspace: true }));
expect(r.next).toBe("ab");
expect(r.cursor).toBe(1);
});
});
describe("processMultilineKey — cursor motion", () => {
it("←/→ clamp to the buffer", () => {
expect(processMultilineKey("abc", 2, key({ leftArrow: true })).cursor).toBe(1);
expect(processMultilineKey("abc", 0, key({ leftArrow: true })).cursor).toBe(0);
expect(processMultilineKey("abc", 2, key({ rightArrow: true })).cursor).toBe(3);
expect(processMultilineKey("abc", 3, key({ rightArrow: true })).cursor).toBe(3);
});
it("↑/↓ on a single-line non-empty buffer is a no-op (doesn't eat for history)", () => {
// Single-line buffer: moveCursorUp has nowhere to go. Returns NOOP so
// the parent can decide what to do — but parent only does history on
// empty buffer, so the net effect is "nothing happens."
const up = processMultilineKey("hello", 3, key({ upArrow: true }));
expect(up).toEqual({ next: null, cursor: null, submit: false });
const down = processMultilineKey("hello", 3, key({ downArrow: true }));
expect(down).toEqual({ next: null, cursor: null, submit: false });
});
it("↑/↓ on an empty buffer defers to the parent (history recall)", () => {
expect(processMultilineKey("", 0, key({ upArrow: true }))).toEqual({
next: null,
cursor: null,
submit: false,
});
expect(processMultilineKey("", 0, key({ downArrow: true }))).toEqual({
next: null,
cursor: null,
submit: false,
});
});
it("↑ moves cursor to the previous line, preserving column when possible", () => {
// line 0: "hello" (cols 0-5)
// line 1: "world" (cols 0-5)
// cursor at col 3 on line 1 = index 5 (for "\n") + 3 + 1 = 9
const v = "hello\nworld";
const up = processMultilineKey(v, 9, key({ upArrow: true }));
// target: line 0 col 3 → index 3
expect(up.cursor).toBe(3);
});
it("↑ clamps column when the previous line is shorter", () => {
const v = "hi\nworld";
// cursor at line 1 col 4 = index 3 + 4 = 7
const up = processMultilineKey(v, 7, key({ upArrow: true }));
// target: line 0 col min(4, 2) = 2 → index 2
expect(up.cursor).toBe(2);
});
it("↓ moves cursor to the next line, preserving column", () => {
const v = "hello\nworld";
// cursor at line 0 col 2 = index 2
const down = processMultilineKey(v, 2, key({ downArrow: true }));
// target: line 1 col 2 → index 6 + 2 = 8
expect(down.cursor).toBe(8);
});
it("↓ clamps column when the next line is shorter", () => {
const v = "world\nhi";
// cursor at line 0 col 4 = index 4
const down = processMultilineKey(v, 4, key({ downArrow: true }));
// target: line 1 col min(4, 2) = 2 → index 6 + 2 = 8
expect(down.cursor).toBe(8);
});
it("Ctrl+A jumps to start of current line, Ctrl+E to end", () => {
const v = "one\ntwo\nthree";
// cursor mid-"two" at index 5 (o in two)
expect(processMultilineKey(v, 5, key({ input: "a", ctrl: true })).cursor).toBe(4);
expect(processMultilineKey(v, 5, key({ input: "e", ctrl: true })).cursor).toBe(7);
});
});
describe("processMultilineKey — parent-owned keys are ignored", () => {
it("Tab / Escape / PageUp / PageDown are dropped", () => {
expect(processMultilineKey("x", 1, key({ tab: true }))).toEqual({
next: null,
cursor: null,
submit: false,
});
expect(processMultilineKey("x", 1, key({ escape: true })).next).toBeNull();
expect(processMultilineKey("x", 1, key({ pageUp: true })).next).toBeNull();
expect(processMultilineKey("x", 1, key({ pageDown: true })).next).toBeNull();
});
it("unhandled Ctrl-<letter> chords are dropped (no accidental insert)", () => {
const r = processMultilineKey("x", 1, key({ input: "c", ctrl: true }));
expect(r.next).toBeNull();
expect(r.cursor).toBeNull();
});
it("Meta (Alt) key events are dropped", () => {
const r = processMultilineKey("x", 1, key({ input: "a", meta: true }));
expect(r.next).toBeNull();
});
});
describe("lineAndColumn", () => {
it("maps a cursor offset to {line, col}", () => {
expect(lineAndColumn("abc", 2)).toEqual({ line: 0, col: 2 });
expect(lineAndColumn("abc\ndef", 4)).toEqual({ line: 1, col: 0 });
expect(lineAndColumn("abc\ndef", 6)).toEqual({ line: 1, col: 2 });
expect(lineAndColumn("a\n\nb", 2)).toEqual({ line: 1, col: 0 });
expect(lineAndColumn("a\n\nb", 3)).toEqual({ line: 2, col: 0 });
});
it("clamps cursor values past value.length", () => {
expect(lineAndColumn("abc", 99)).toEqual({ line: 0, col: 3 });
});
});
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from "vitest";
import { ToolRegistry } from "../src/tools.js";
import {
formatSearchResults,
htmlToText,
parseMojeekResults,
registerWebTools,
webFetch,
webSearch,
} from "../src/tools/web.js";
describe("htmlToText", () => {
it("strips script/style/nav/footer and preserves paragraph breaks", () => {
const html = `
<html><head><title>x</title><style>body{color:red}</style></head>
<body>
<nav><a>skip</a></nav>
<p>Hello <strong>world</strong>.</p>
<p>Second paragraph.</p>
<script>evil()</script>
<footer>fine print</footer>
</body></html>
`;
const out = htmlToText(html);
expect(out).toContain("Hello world.");
expect(out).toContain("Second paragraph.");
expect(out).not.toContain("evil");
expect(out).not.toContain("skip");
expect(out).not.toContain("fine print");
expect(out).not.toContain("color:red");
expect(out).toMatch(/Hello world\.\n\nSecond paragraph\./);
});
it("decodes the common entities", () => {
expect(htmlToText("<p>a &amp; b &lt;c&gt; &quot;d&quot;</p>")).toContain('a & b <c> "d"');
});
it("collapses whitespace runs but keeps paragraph breaks", () => {
const out = htmlToText("<p>one two</p><p>three</p>");
expect(out).toBe("one two\n\nthree");
});
});
describe("parseMojeekResults", () => {
// Fixture mirrors the shape Mojeek actually returns as of April 2026.
const sampleHtml = `
<ul class="results">
<li>
<a title="https://example.com/a" href="https://example.com/a" class="ob">
<p class="i"><span class="url">https://example.com</span></p>
</a>
<h2>
<a class="title" title="https://example.com/a" href="https://example.com/a">
Flutter 3.19 release notes
</a>
</h2>
<p class="s">
Flutter 3.19 introduces <strong>new Navigator</strong>&nbsp;APIs &amp; more.
</p>
</li>
<li>
<a href="https://medium.com/flutter/x" class="ob">
<p class="i"><span class="url">medium.com</span></p>
</a>
<h2>
<a class="title" href="https://medium.com/flutter/x">What's new in 3.19</a>
</h2>
<p class="s">An overview post.</p>
</li>
</ul>
`;
it("extracts title/url/snippet from the expected markup", () => {
const items = parseMojeekResults(sampleHtml);
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
title: "Flutter 3.19 release notes",
url: "https://example.com/a",
snippet: "Flutter 3.19 introduces new Navigator APIs & more.",
});
expect(items[1]).toEqual({
title: "What's new in 3.19",
url: "https://medium.com/flutter/x",
snippet: "An overview post.",
});
});
it("returns empty on markup that doesn't match the expected shape", () => {
expect(parseMojeekResults("<html><body>nothing here</body></html>")).toEqual([]);
});
it("tolerates attribute-order swaps (href before class)", () => {
const html = `
<a href="https://example.com/z" class="title">Title Z</a>
<p class="s">Snippet Z.</p>
`;
const items = parseMojeekResults(html);
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
title: "Title Z",
url: "https://example.com/z",
snippet: "Snippet Z.",
});
});
it("handles a title with no snippet sibling (empty snippet)", () => {
const html = `<a class="title" href="https://example.com/s">Solo</a>`;
const items = parseMojeekResults(html);
expect(items).toHaveLength(1);
expect(items[0]?.snippet).toBe("");
});
});
describe("webSearch", () => {
const twoResultsHtml = `
<a class="title" href="https://example.com/a">A</a>
<p class="s">snippet A</p>
<a class="title" href="https://example.com/b">B</a>
<p class="s">snippet B</p>`;
it("GETs Mojeek with a browser UA and query string", async () => {
const captured: { url: string; method: string; ua: string } = { url: "", method: "", ua: "" };
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
captured.url = String(url);
captured.method = init?.method ?? "GET";
const headers = (init?.headers ?? {}) as Record<string, string>;
captured.ua = headers["User-Agent"] ?? "";
return new Response(twoResultsHtml, {
status: 200,
headers: { "Content-Type": "text/html" },
});
}) as unknown as typeof fetch;
try {
const out = await webSearch("flutter 3.19", { topK: 2 });
expect(captured.url).toContain("mojeek.com/search");
expect(captured.url).toContain("q=flutter%203.19");
expect(captured.method).toBe("GET");
expect(captured.ua).toMatch(/Mozilla\/5.0/);
expect(out).toHaveLength(2);
expect(out[0]?.title).toBe("A");
} finally {
globalThis.fetch = originalFetch;
}
});
it("clamps topK to [1, 10]", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () => new Response(twoResultsHtml, { status: 200 }),
) as unknown as typeof fetch;
try {
const outMax = await webSearch("x", { topK: 99 });
expect(outMax.length).toBeLessThanOrEqual(10);
const outMin = await webSearch("x", { topK: 0 });
expect(outMin.length).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
it("throws on non-2xx", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () => new Response("blocked", { status: 429 }),
) as unknown as typeof fetch;
try {
await expect(webSearch("q")).rejects.toThrow(/web_search 429/);
} finally {
globalThis.fetch = originalFetch;
}
});
it("returns [] on a legitimately empty 'No results' page", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () =>
new Response("<html><body>Your search did not match any documents.</body></html>", {
status: 200,
}),
) as unknown as typeof fetch;
try {
const out = await webSearch("zzyzx nothing here");
expect(out).toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
it("surfaces a clear error on an anti-bot interstitial", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () =>
new Response("<html>Please solve the captcha to continue.</html>", { status: 200 }),
) as unknown as typeof fetch;
try {
await expect(webSearch("q")).rejects.toThrow(/anti-bot|rate-limited|blocked/);
} finally {
globalThis.fetch = originalFetch;
}
});
it("surfaces a diagnostic error when the response looks like neither empty-nor-results", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () =>
new Response("<html><body>totally unexpected shape</body></html>", { status: 200 }),
) as unknown as typeof fetch;
try {
await expect(webSearch("q")).rejects.toThrow(/doesn't look like a real empty page/);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("formatSearchResults", () => {
it("renders a query header + numbered list", () => {
const out = formatSearchResults("hello", [
{ title: "One", url: "https://one", snippet: "first" },
{ title: "Two", url: "https://two", snippet: "second" },
]);
expect(out).toMatch(/query: hello/);
expect(out).toMatch(/results \(2\)/);
expect(out).toMatch(/1\. One\n\s+https:\/\/one\n\s+first/);
expect(out).toMatch(/2\. Two/);
});
});
describe("registerWebTools", () => {
it("registers web_search and web_fetch", () => {
const registry = new ToolRegistry();
registerWebTools(registry);
expect(registry.size).toBe(2);
});
it("web_fetch refuses non-http(s) urls", async () => {
const registry = new ToolRegistry();
registerWebTools(registry);
const out = await registry.dispatch("web_fetch", JSON.stringify({ url: "file:///etc/passwd" }));
expect(out).toMatch(/must start with http/);
});
});
describe("webFetch", () => {
it("extracts title + body text from an html response", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () =>
new Response(
"<html><head><title>Demo</title></head><body><p>Hello world.</p></body></html>",
{ status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } },
),
) as unknown as typeof fetch;
try {
const page = await webFetch("https://example.com/demo");
expect(page.title).toBe("Demo");
expect(page.text).toContain("Hello world.");
expect(page.text).not.toContain("<title>");
expect(page.truncated).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it("truncates long pages and flags the result", async () => {
const originalFetch = globalThis.fetch;
const big = `<html><body><p>${"a".repeat(50_000)}</p></body></html>`;
globalThis.fetch = vi.fn(
async () => new Response(big, { status: 200, headers: { "Content-Type": "text/html" } }),
) as unknown as typeof fetch;
try {
const page = await webFetch("https://example.com/big", { maxChars: 1000 });
expect(page.truncated).toBe(true);
expect(page.text).toMatch(/\[… truncated \d+ chars …\]$/);
} finally {
globalThis.fetch = originalFetch;
}
});
it("surfaces non-2xx as a thrown error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(
async () => new Response("nope", { status: 404 }),
) as unknown as typeof fetch;
try {
await expect(webFetch("https://example.com/missing")).rejects.toThrow(/web_fetch 404/);
} finally {
globalThis.fetch = originalFetch;
}
});
});