mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd344986e8 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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");
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 & b <c> "d"</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> APIs & 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user